Anurag commited on
Commit
233a0dc
Β·
unverified Β·
1 Parent(s): e19a071

Refactor key rotator and merge kimi-coding with moonshot

Browse files

Refactor multi-provider key rotator to improve key management and error handling. Merge kimi-coding and moonshot providers due to shared hostname.

Files changed (1) hide show
  1. multi-provider-key-rotator.cjs +267 -307
multi-provider-key-rotator.cjs CHANGED
@@ -3,279 +3,248 @@
3
  /**
4
  * Multi-provider API key rotator for OpenClaw/HuggingClaw
5
  * --------------------------------------------------------
6
- * Works exactly like nvidia-key-rotator.cjs but covers every
7
- * provider that HuggingClaw supports.
 
 
 
8
  *
9
- * For each provider you can supply a comma-separated pool:
10
- * ANTHROPIC_API_KEYS=key1,key2,key3
11
- * Falls back to the singular env var, and optionally to LLM_API_KEY.
12
- *
13
- * Keys are rotated round-robin per provider independently.
14
- *
15
- * Patches globalThis.fetch + node:http + node:https so that
16
- * virtually every caller is covered without code changes.
17
  */
18
 
19
  const http = require('node:http');
20
  const https = require('node:https');
21
 
22
- // This file is preloaded through NODE_OPTIONS, so it also runs inside npm and
23
- // OpenClaw helper subprocesses that may emit machine-readable JSON on stdout.
24
- // Keep rotator diagnostics on stderr to avoid corrupting those stdout streams.
25
- const log = (...args) => console.error(...args);
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  // ─── Provider definitions ────────────────────────────────────────────────────
28
- //
29
- // hostname – regex tested against the request hostname (case-insensitive)
30
- // envPlural – env var that holds a comma-separated key pool (preferred)
31
- // envSingular – env var that holds a single key (fallback)
32
- //
33
- // LLM_API_KEY fallback can be controlled via:
34
- // LLM_API_KEY_FALLBACK_ENABLED=true|false
35
- // Default is enabled for backwards compatibility.
36
- //
37
- const PROVIDERS = [
38
- {
39
- name: 'anthropic',
40
- hostname: /(?:^|\.)api\.anthropic\.com$/i,
41
- envPlural: 'ANTHROPIC_API_KEYS',
42
- envSingular:'ANTHROPIC_API_KEY',
43
- },
44
- {
45
- name: 'openai',
46
- hostname: /(?:^|\.)api\.openai\.com$/i,
47
- envPlural: 'OPENAI_API_KEYS',
48
- envSingular:'OPENAI_API_KEY',
49
- },
50
- {
51
- name: 'gemini',
52
- // Gemini uses generativelanguage API; also covers aiplatform
53
- hostname: /(?:^|\.)(?:generativelanguage\.googleapis\.com|aiplatform\.googleapis\.com)$/i,
54
- envPlural: 'GEMINI_API_KEYS',
55
- envSingular:'GEMINI_API_KEY',
56
- queryParam: true,
57
- },
58
- {
59
- name: 'deepseek',
60
- hostname: /(?:^|\.)api\.deepseek\.com$/i,
61
- envPlural: 'DEEPSEEK_API_KEYS',
62
- envSingular:'DEEPSEEK_API_KEY',
63
- },
64
- {
65
- name: 'openrouter',
66
- hostname: /(?:^|\.)openrouter\.ai$/i,
67
- envPlural: 'OPENROUTER_API_KEYS',
68
- envSingular:'OPENROUTER_API_KEY',
69
- },
70
- {
71
- name: 'kilocode',
72
- hostname: /(?:^|\.)kilocode\.ai$/i,
73
- envPlural: 'KILOCODE_API_KEYS',
74
- envSingular:'KILOCODE_API_KEY',
75
- },
76
- {
77
- name: 'opencode',
78
- hostname: /(?:^|\.)opencode\.ai$/i,
79
- envPlural: 'OPENCODE_API_KEYS',
80
- envSingular:'OPENCODE_API_KEY',
81
- },
82
- {
83
- name: 'zai',
84
- // Z.ai / GLM β€” both domains normalised to "zai" in OpenClaw
85
- hostname: /(?:^|\.)(?:z\.ai|open\.bigmodel\.cn)$/i,
86
- envPlural: 'ZAI_API_KEYS',
87
- envSingular:'ZAI_API_KEY',
88
- },
89
- {
90
- name: 'kimi-coding',
91
- // kimi-coding routes through api.moonshot.cn; dedicated entry so KIMI_API_KEYS pool is used
92
- hostname: /(?:^|\.)api\.moonshot\.cn$/i,
93
- envPlural: 'KIMI_API_KEYS',
94
- envSingular:'KIMI_API_KEY',
95
- },
96
- {
97
- name: 'moonshot',
98
- hostname: /(?:^|\.)api\.moonshot\.cn$/i,
99
- envPlural: 'MOONSHOT_API_KEYS',
100
- envSingular:'MOONSHOT_API_KEY',
101
- },
102
- {
103
- name: 'minimax',
104
- hostname: /(?:^|\.)api\.minimax\.chat$/i,
105
- envPlural: 'MINIMAX_API_KEYS',
106
- envSingular:'MINIMAX_API_KEY',
107
- },
108
- {
109
- name: 'xiaomi',
110
- // MiMo β€” update hostname if Xiaomi publishes an official domain
111
- hostname: /(?:^|\.)api\.xiaomi\.com$/i,
112
- envPlural: 'XIAOMI_API_KEYS',
113
- envSingular:'XIAOMI_API_KEY',
114
- },
115
- {
116
- name: 'volcengine',
117
- // Volcengine / Doubao
118
- hostname: /(?:^|\.)(?:ark\.cn-beijing\.volces\.com|volcengineapi\.com)$/i,
119
- envPlural: 'VOLCANO_ENGINE_API_KEYS',
120
- envSingular:'VOLCANO_ENGINE_API_KEY',
121
- },
122
- {
123
- name: 'byteplus',
124
- hostname: /(?:^|\.)maas-api\.ml-platform-cn-beijing\.byteplus\.com$/i,
125
- envPlural: 'BYTEPLUS_API_KEYS',
126
- envSingular:'BYTEPLUS_API_KEY',
127
- },
128
- {
129
- name: 'mistral',
130
- hostname: /(?:^|\.)api\.mistral\.ai$/i,
131
- envPlural: 'MISTRAL_API_KEYS',
132
- envSingular:'MISTRAL_API_KEY',
133
- },
134
- {
135
- name: 'xai',
136
- hostname: /(?:^|\.)api\.x\.ai$/i,
137
- envPlural: 'XAI_API_KEYS',
138
- envSingular:'XAI_API_KEY',
139
- },
140
- {
141
- name: 'nvidia',
142
- hostname: /(?:^|\.)(?:integrate\.api\.nvidia\.com|api\.nvidia\.com)$/i,
143
- envPlural: 'NVIDIA_API_KEYS',
144
- envSingular:'NVIDIA_API_KEY',
145
- },
146
- {
147
- name: 'groq',
148
- hostname: /(?:^|\.)api\.groq\.com$/i,
149
- envPlural: 'GROQ_API_KEYS',
150
- envSingular:'GROQ_API_KEY',
151
- },
152
- {
153
- name: 'cohere',
154
- hostname: /(?:^|\.)api\.cohere\.(?:ai|com)$/i,
155
- envPlural: 'COHERE_API_KEYS',
156
- envSingular:'COHERE_API_KEY',
157
- },
158
- {
159
- name: 'together',
160
- hostname: /(?:^|\.)api\.together\.(?:xyz|ai)$/i,
161
- envPlural: 'TOGETHER_API_KEYS',
162
- envSingular:'TOGETHER_API_KEY',
163
- },
164
- {
165
- name: 'cerebras',
166
- hostname: /(?:^|\.)api\.cerebras\.ai$/i,
167
- envPlural: 'CEREBRAS_API_KEYS',
168
- envSingular:'CEREBRAS_API_KEY',
169
- },
170
- {
171
- name: 'huggingface',
172
- hostname: /(?:^|\.)(?:api-inference\.huggingface\.co|router\.huggingface\.co|huggingface\.co)$/i,
173
- envPlural: 'HUGGINGFACE_HUB_TOKENS', // plural variant
174
- envSingular:'HUGGINGFACE_HUB_TOKEN',
175
- },
176
- {
177
- name: 'venice',
178
- hostname: /(?:^|\.)api\.venice\.ai$/i,
179
- envPlural: 'VENICE_API_KEYS',
180
- envSingular:'VENICE_API_KEY',
181
- },
182
- {
183
- name: 'github-copilot',
184
- hostname: /(?:^|\.)api\.githubcopilot\.com$/i,
185
- envPlural: 'COPILOT_GITHUB_TOKENS',
186
- envSingular:'COPILOT_GITHUB_TOKEN',
187
- },
188
- {
189
- name: 'qianfan',
190
- // Baidu Qianfan / ERNIE
191
- hostname: /(?:^|\.)(?:aip|qianfan)\.baidubce\.com$/i,
192
- envPlural: 'QIANFAN_API_KEYS',
193
- envSingular:'QIANFAN_API_KEY',
194
- },
195
- {
196
- name: 'modelstudio',
197
- // Aliyun DashScope / Qwen (both qwen/* and modelstudio/* prefixes)
198
- hostname: /(?:^|\.)dashscope\.aliyuncs\.com$/i,
199
- envPlural: 'MODELSTUDIO_API_KEYS',
200
- envSingular:'MODELSTUDIO_API_KEY',
201
- },
202
- {
203
- name: 'synthetic',
204
- hostname: /(?:^|\.)synthetic\.local$/i,
205
- envPlural: 'SYNTHETIC_API_KEYS',
206
- envSingular: 'SYNTHETIC_API_KEY',
207
- },
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  ];
210
 
211
  // ─── Key loading ─────────────────────────────────────────────────────────────
212
 
213
  function normalizeKeys(...inputs) {
214
- const seen = new Set();
215
- const out = [];
216
- for (const input of inputs) {
217
- for (const k of String(input || '').split(',').map(s => s.trim()).filter(Boolean)) {
218
  if (!seen.has(k)) { seen.add(k); out.push(k); }
219
- }
220
- }
221
  return out;
222
  }
223
 
224
- // Build per-provider key pools + rotation indices
 
 
 
 
225
  const providerState = PROVIDERS.map(p => {
226
- const llmFallbackRaw = String(process.env.LLM_API_KEY_FALLBACK_ENABLED || '').trim().toLowerCase();
227
- const llmFallbackEnabled = !/^(0|false|no|off)$/.test(llmFallbackRaw);
 
 
 
 
 
 
228
  const dedicatedKeys = normalizeKeys(
229
  process.env[p.envPlural] || '',
230
  process.env[p.envSingular] || '',
 
231
  );
232
  const hasDedicated = dedicatedKeys.length > 0;
233
  const keys = hasDedicated
234
  ? dedicatedKeys
235
- : (
236
- llmFallbackEnabled
237
- ? normalizeKeys(process.env.LLM_API_KEY || '')
238
- : []
239
- );
240
 
241
- if (hasDedicated) {
242
  log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
243
- } else if (!keys.length) {
244
- console.warn(`[key-rotator] No keys for provider "${p.name}"`);
245
- }
246
 
247
- return { ...p, keys, idx: 0 };
 
 
 
 
 
 
248
  });
249
 
250
- // Summarise providers that fall back to LLM_API_KEY
251
  const fallbackCount = providerState.filter(p => {
252
- const dedicated = normalizeKeys(
253
- process.env[p.envPlural] || '',
254
- process.env[p.envSingular] || '',
255
- );
256
- return dedicated.length === 0 && p.keys.length > 0;
257
  }).length;
258
- if (fallbackCount > 0) {
259
  log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
260
- } else if (process.env.LLM_API_KEY && /^(0|false|no|off)$/i.test(String(process.env.LLM_API_KEY_FALLBACK_ENABLED || ''))) {
261
- log('[key-rotator] LLM_API_KEY fallback disabled (set LLM_API_KEY_FALLBACK_ENABLED=true to re-enable)');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  }
263
 
264
- // ─── Runtime helpers ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
  function resolveHostname(urlLike) {
267
  try {
268
  const u =
269
- typeof urlLike === 'string' ? new URL(urlLike)
270
- : urlLike instanceof URL ? urlLike
271
- : urlLike && typeof urlLike.url === 'string' ? new URL(urlLike.url)
272
- : urlLike && typeof urlLike.href === 'string' ? new URL(urlLike.href)
273
- : urlLike && typeof urlLike.hostname === 'string' ? urlLike
274
  : null;
275
  return u ? u.hostname : null;
276
- } catch {
277
- return null;
278
- }
279
  }
280
 
281
  function matchProvider(hostname) {
@@ -283,144 +252,135 @@ function matchProvider(hostname) {
283
  return providerState.find(p => p.hostname.test(hostname)) || null;
284
  }
285
 
286
- function nextKey(provider) {
287
- if (!provider || !provider.keys.length) return null;
288
- const key = provider.keys[provider.idx % provider.keys.length];
289
- provider.idx = (provider.idx + 1) % provider.keys.length;
290
- return key;
291
- }
292
-
293
  function setAuthHeader(headers, key) {
294
  if (!key) return headers;
295
- const authValue = `Bearer ${key}`;
296
-
297
  if (typeof Headers !== 'undefined' && headers instanceof Headers) {
298
- headers.set('authorization', authValue);
299
- return headers;
300
  }
301
  if (Array.isArray(headers)) {
302
- const out = headers.filter(([k]) => String(k).toLowerCase() !== 'authorization');
303
- out.push(['authorization', authValue]);
304
- return out;
305
  }
306
- if (headers && typeof headers === 'object') {
307
- return { ...headers, authorization: authValue };
 
 
 
 
 
 
 
 
308
  }
309
- return { authorization: authValue };
310
  }
311
 
312
  // ─── Patch globalThis.fetch ───────────────────────────────────────────────────
313
 
314
  function patchFetch() {
315
  if (typeof globalThis.fetch !== 'function') return;
316
-
317
- const originalFetch = globalThis.fetch.bind(globalThis);
318
 
319
  globalThis.fetch = async function patchedFetch(input, init = {}) {
320
- try {
321
- const urlLike =
322
- typeof input === 'string' || input instanceof URL
323
- ? input
324
- : input && typeof input.url === 'string' ? input.url : null;
325
 
326
- const hostname = resolveHostname(urlLike);
327
- const provider = matchProvider(hostname);
 
 
 
328
 
329
- if (provider) {
330
  const key = nextKey(provider);
331
  if (key) {
 
332
  if (provider.queryParam) {
333
- // Gemini: key URL query param mein jaata hai, Bearer nahi
334
  const url = new URL(typeof input === 'string' ? input : input.url);
335
  url.searchParams.set('key', key);
336
  if (typeof input === 'string') {
337
  input = url.toString();
338
  } else {
339
- // Do NOT pass the Request object as init β€” that clones (consumes) the body stream.
340
- // Instead patch only the URL via init object; fetch spec merges headers from Request.
341
- init = {
342
- method: input.method,
343
- headers: input.headers,
344
- body: input.body,
345
- mode: input.mode,
346
- credentials: input.credentials,
347
- cache: input.cache,
348
- redirect: input.redirect,
349
- referrer: input.referrer,
350
- integrity: input.integrity,
351
- ...init,
352
- };
353
  input = url.toString();
354
  }
355
  } else {
356
- const headers = init.headers || (input && input.headers) || undefined;
357
- const patchedHeaders = setAuthHeader(headers, key);
358
- init = { ...init, headers: patchedHeaders };
359
- // NOTE: new Request(input, {headers}) yahan nahi karte β€” Request clone karna
360
- // body stream ko disturb kar deta hai β†’ UND_ERR_INVALID_ARG on POST requests.
361
- // init.headers fetch spec ke mutabiq Request ke headers ko override kar deta hai.
362
  }
363
  }
364
  }
365
- } catch (err) {
366
- console.warn('[key-rotator] fetch patch error:', err?.message || err);
367
- }
368
 
369
- return originalFetch(input, init);
 
 
 
 
 
370
  };
371
  }
372
 
373
  // ─── Patch node:http / node:https ────────────────────────────────────────────
374
 
375
  function patchHttpModule(mod) {
376
- const originalRequest = mod.request;
377
 
378
  mod.request = function patchedRequest(...args) {
 
 
379
  try {
380
  const options = args[0];
381
- const hostname = resolveHostname(options);
382
- const provider = matchProvider(hostname);
383
 
384
  if (provider) {
385
  const key = nextKey(provider);
386
  if (key) {
 
387
  if (provider.queryParam) {
388
- // Gemini: ?key= query param use karo
389
- const u = new URL(String(typeof options === 'string' || options instanceof URL ? options : `https://${options.hostname}${options.path || '/'}`));
 
 
 
390
  u.searchParams.set('key', key);
391
  args[0] = typeof options === 'object' && !(options instanceof URL)
392
- ? { ...options, path: `${u.pathname}${u.search}` }
393
  : u.toString();
394
  } else if (typeof options === 'string' || options instanceof URL) {
395
- // Convert string/URL to options object and inject auth header.
396
- // Also preserve any extra options passed as args[1] (3-arg form of http.request).
397
  const u = new URL(String(options));
398
- const extraOpts = (args[1] && typeof args[1] === 'object' && typeof args[1].on !== 'function') ? args[1] : {};
399
- args[0] = {
400
- protocol: u.protocol,
401
- hostname: u.hostname,
402
- port: u.port,
403
- path: `${u.pathname}${u.search}`,
404
- ...extraOpts,
405
- headers: setAuthHeader(extraOpts.headers, key),
406
- };
407
  } else if (options && typeof options === 'object') {
408
- args[0] = { ...options, headers: setAuthHeader(options.headers, key) };
409
  }
410
  }
411
  }
412
- } catch (err) {
413
- console.warn('[key-rotator] http patch error:', err?.message || err);
414
- }
415
 
416
- return originalRequest.apply(mod, args);
 
 
 
 
 
 
 
 
 
 
 
417
  };
418
  }
419
 
420
- // ─── Apply patches ────────────────────────────────────────────────────────────
421
 
422
  patchFetch();
423
  patchHttpModule(http);
424
  patchHttpModule(https);
425
 
426
- log('[key-rotator] loaded β€” all providers active');
 
3
  /**
4
  * Multi-provider API key rotator for OpenClaw/HuggingClaw
5
  * --------------------------------------------------------
6
+ * - Round-robin rotation per provider
7
+ * - 429/402 β†’ exponential backoff blacklist per key
8
+ * - After MAX_STRIKES consecutive failures β†’ permanent session blacklist
9
+ * - Successful response β†’ strikes reset
10
+ * - 10+ keys handled correctly (idx tracks only active keys, no drift)
11
  *
12
+ * Env vars:
13
+ * KEY_BLACKLIST_COOLDOWN_MS base backoff ms (default 60 000)
14
+ * KEY_MAX_STRIKES failures before perm (default 3)
15
+ * LLM_API_KEY_FALLBACK_ENABLED true/false (default true)
 
 
 
 
16
  */
17
 
18
  const http = require('node:http');
19
  const https = require('node:https');
20
 
21
+ const log = (...a) => console.error(...a);
22
+ const warn = (...a) => console.warn(...a);
23
+
24
+ // ─── Config ──────────────────────────────────────────────────────────────────
25
+
26
+ const BASE_COOLDOWN_MS = Math.max(
27
+ 1000,
28
+ parseInt(process.env.KEY_BLACKLIST_COOLDOWN_MS || '', 10) || 60_000,
29
+ );
30
+ const MAX_STRIKES = Math.max(
31
+ 1,
32
+ parseInt(process.env.KEY_MAX_STRIKES || '', 10) || 3,
33
+ );
34
+ // Permanently blacklisted keys retry after this long (default 24 h).
35
+ // "Permanent" just means very long β€” avoids truly forever loops on app restart.
36
+ const PERM_BLACKLIST_MS = 24 * 60 * 60 * 1000;
37
 
38
  // ─── Provider definitions ────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ const PROVIDERS = [
41
+ { name:'anthropic', hostname:/(?:^|\.)api\.anthropic\.com$/i, envPlural:'ANTHROPIC_API_KEYS', envSingular:'ANTHROPIC_API_KEY' },
42
+ { name:'openai', hostname:/(?:^|\.)api\.openai\.com$/i, envPlural:'OPENAI_API_KEYS', envSingular:'OPENAI_API_KEY' },
43
+ { name:'gemini', hostname:/(?:^|\.)(?:generativelanguage\.googleapis\.com|aiplatform\.googleapis\.com)$/i,
44
+ envPlural:'GEMINI_API_KEYS', envSingular:'GEMINI_API_KEY', queryParam:true },
45
+ { name:'deepseek', hostname:/(?:^|\.)api\.deepseek\.com$/i, envPlural:'DEEPSEEK_API_KEYS', envSingular:'DEEPSEEK_API_KEY' },
46
+ { name:'openrouter', hostname:/(?:^|\.)openrouter\.ai$/i, envPlural:'OPENROUTER_API_KEYS', envSingular:'OPENROUTER_API_KEY' },
47
+ { name:'kilocode', hostname:/(?:^|\.)kilocode\.ai$/i, envPlural:'KILOCODE_API_KEYS', envSingular:'KILOCODE_API_KEY' },
48
+ { name:'opencode', hostname:/(?:^|\.)opencode\.ai$/i, envPlural:'OPENCODE_API_KEYS', envSingular:'OPENCODE_API_KEY' },
49
+ { name:'zai', hostname:/(?:^|\.)(?:z\.ai|open\.bigmodel\.cn)$/i, envPlural:'ZAI_API_KEYS', envSingular:'ZAI_API_KEY' },
50
+ // FIX: kimi-coding aur moonshot ek hi hostname share karte hain (api.moonshot.cn).
51
+ // Purani file mein dono alag entries thi β€” find() hamesha kimi-coding pick karta tha,
52
+ // MOONSHOT_API_KEYS kabhi use nahi hoti. Ab merged entry: dono pools combine honge.
53
+ { name:'kimi-moonshot',hostname:/(?:^|\.)api\.moonshot\.cn$/i, envPlural:'KIMI_API_KEYS', envSingular:'KIMI_API_KEY',
54
+ _extraPlural:'MOONSHOT_API_KEYS', _extraSingular:'MOONSHOT_API_KEY' },
55
+ { name:'minimax', hostname:/(?:^|\.)api\.minimax\.chat$/i, envPlural:'MINIMAX_API_KEYS', envSingular:'MINIMAX_API_KEY' },
56
+ { name:'xiaomi', hostname:/(?:^|\.)api\.xiaomi\.com$/i, envPlural:'XIAOMI_API_KEYS', envSingular:'XIAOMI_API_KEY' },
57
+ { name:'volcengine', hostname:/(?:^|\.)(?:ark\.cn-beijing\.volces\.com|volcengineapi\.com)$/i,
58
+ envPlural:'VOLCANO_ENGINE_API_KEYS', envSingular:'VOLCANO_ENGINE_API_KEY' },
59
+ { name:'byteplus', hostname:/(?:^|\.)maas-api\.ml-platform-cn-beijing\.byteplus\.com$/i,
60
+ envPlural:'BYTEPLUS_API_KEYS', envSingular:'BYTEPLUS_API_KEY' },
61
+ { name:'mistral', hostname:/(?:^|\.)api\.mistral\.ai$/i, envPlural:'MISTRAL_API_KEYS', envSingular:'MISTRAL_API_KEY' },
62
+ { name:'xai', hostname:/(?:^|\.)api\.x\.ai$/i, envPlural:'XAI_API_KEYS', envSingular:'XAI_API_KEY' },
63
+ { name:'nvidia', hostname:/(?:^|\.)(?:integrate\.api\.nvidia\.com|api\.nvidia\.com)$/i,
64
+ envPlural:'NVIDIA_API_KEYS', envSingular:'NVIDIA_API_KEY' },
65
+ { name:'groq', hostname:/(?:^|\.)api\.groq\.com$/i, envPlural:'GROQ_API_KEYS', envSingular:'GROQ_API_KEY' },
66
+ { name:'cohere', hostname:/(?:^|\.)api\.cohere\.(?:ai|com)$/i, envPlural:'COHERE_API_KEYS', envSingular:'COHERE_API_KEY' },
67
+ { name:'together', hostname:/(?:^|\.)api\.together\.(?:xyz|ai)$/i, envPlural:'TOGETHER_API_KEYS', envSingular:'TOGETHER_API_KEY' },
68
+ { name:'cerebras', hostname:/(?:^|\.)api\.cerebras\.ai$/i, envPlural:'CEREBRAS_API_KEYS', envSingular:'CEREBRAS_API_KEY' },
69
+ { name:'huggingface', hostname:/(?:^|\.)(?:api-inference\.huggingface\.co|router\.huggingface\.co|huggingface\.co)$/i,
70
+ envPlural:'HUGGINGFACE_HUB_TOKENS', envSingular:'HUGGINGFACE_HUB_TOKEN' },
71
+ { name:'venice', hostname:/(?:^|\.)api\.venice\.ai$/i, envPlural:'VENICE_API_KEYS', envSingular:'VENICE_API_KEY' },
72
+ { name:'github-copilot',hostname:/(?:^|\.)api\.githubcopilot\.com$/i, envPlural:'COPILOT_GITHUB_TOKENS', envSingular:'COPILOT_GITHUB_TOKEN' },
73
+ { name:'qianfan', hostname:/(?:^|\.)(?:aip|qianfan)\.baidubce\.com$/i, envPlural:'QIANFAN_API_KEYS', envSingular:'QIANFAN_API_KEY' },
74
+ { name:'modelstudio', hostname:/(?:^|\.)dashscope\.aliyuncs\.com$/i, envPlural:'MODELSTUDIO_API_KEYS', envSingular:'MODELSTUDIO_API_KEY' },
75
+ { name:'synthetic', hostname:/(?:^|\.)synthetic\.local$/i, envPlural:'SYNTHETIC_API_KEYS', envSingular:'SYNTHETIC_API_KEY' },
76
  ];
77
 
78
  // ─── Key loading ─────────────────────────────────────────────────────────────
79
 
80
  function normalizeKeys(...inputs) {
81
+ const seen = new Set(), out = [];
82
+ for (const input of inputs)
83
+ for (const k of String(input || '').split(',').map(s => s.trim()).filter(Boolean))
 
84
  if (!seen.has(k)) { seen.add(k); out.push(k); }
 
 
85
  return out;
86
  }
87
 
88
+ // Per-key state: { strikes, blacklistedUntil }
89
+ // strikes – consecutive 429/402 count; resets on success
90
+ // blacklistedUntil – epoch ms; 0 = active
91
+ function makeKeyState() { return { strikes: 0, blacklistedUntil: 0 }; }
92
+
93
  const providerState = PROVIDERS.map(p => {
94
+ const llmFallbackEnabled = !/^(0|false|no|off)$/.test(
95
+ String(process.env.LLM_API_KEY_FALLBACK_ENABLED || '').trim().toLowerCase(),
96
+ );
97
+
98
+ const extraKeys = (p._extraPlural || p._extraSingular)
99
+ ? normalizeKeys(process.env[p._extraPlural || ''] || '', process.env[p._extraSingular || ''] || '')
100
+ : [];
101
+
102
  const dedicatedKeys = normalizeKeys(
103
  process.env[p.envPlural] || '',
104
  process.env[p.envSingular] || '',
105
+ ...extraKeys,
106
  );
107
  const hasDedicated = dedicatedKeys.length > 0;
108
  const keys = hasDedicated
109
  ? dedicatedKeys
110
+ : (llmFallbackEnabled ? normalizeKeys(process.env.LLM_API_KEY || '') : []);
 
 
 
 
111
 
112
+ if (hasDedicated)
113
  log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
114
+ else if (!keys.length)
115
+ warn(`[key-rotator] No keys for provider "${p.name}"`);
 
116
 
117
+ // keyState: Map<keyString, {strikes, blacklistedUntil}>
118
+ const keyState = new Map(keys.map(k => [k, makeKeyState()]));
119
+
120
+ // FIX: idx tracks position in the ACTIVE (non-permanently-removed) pool.
121
+ // We never remove keys from the array β€” we just skip blacklisted ones.
122
+ // idx advances only when a key is ACTUALLY picked (no drift for skipped keys).
123
+ return { ...p, keys, keyState, idx: 0 };
124
  });
125
 
126
+ // LLM_API_KEY fallback summary
127
  const fallbackCount = providerState.filter(p => {
128
+ const ded = normalizeKeys(process.env[p.envPlural] || '', process.env[p.envSingular] || '');
129
+ return ded.length === 0 && p.keys.length > 0;
 
 
 
130
  }).length;
131
+ if (fallbackCount > 0)
132
  log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
133
+
134
+ // ─── Per-key state helpers ────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Is this key currently sitting out?
138
+ * Also auto-clears expired blacklists so the key re-enters the pool silently.
139
+ */
140
+ function isActive(p, key) {
141
+ const ks = p.keyState.get(key);
142
+ if (!ks) return true; // unknown key β†’ treat as active
143
+ if (ks.blacklistedUntil === 0) return true; // not blacklisted
144
+ if (Date.now() >= ks.blacklistedUntil) {
145
+ ks.blacklistedUntil = 0; // expired β†’ back in pool
146
+ log(`[key-rotator] ${p.name}: ...${key.slice(-6)} back in pool`);
147
+ return true;
148
+ }
149
+ return false;
150
  }
151
 
152
+ /**
153
+ * Called when a key gets a 429/402 response.
154
+ *
155
+ * Strike logic:
156
+ * strike 1 β†’ BASE_COOLDOWN_MS (e.g. 60 s β€” probably rate-limit)
157
+ * strike 2 β†’ BASE_COOLDOWN_MS Γ— 4 (240 s)
158
+ * strike 3 β†’ PERM_BLACKLIST_MS (24 h β€” treat as quota exhausted, skip all day)
159
+ *
160
+ * A successful response resets strikes so a key that was temporarily
161
+ * rate-limited and recovered is treated as fresh again.
162
+ */
163
+ function recordFailure(p, key) {
164
+ let ks = p.keyState.get(key);
165
+ if (!ks) { ks = makeKeyState(); p.keyState.set(key, ks); }
166
+
167
+ ks.strikes = Math.min(ks.strikes + 1, MAX_STRIKES);
168
+
169
+ let cooldown;
170
+ if (ks.strikes >= MAX_STRIKES) {
171
+ cooldown = PERM_BLACKLIST_MS;
172
+ warn(`[key-rotator] ${p.name}: ...${key.slice(-6)} reached ${MAX_STRIKES} strikes β€” suspended for 24 h (quota likely exhausted)`);
173
+ } else {
174
+ // Exponential: 1Γ— β†’ 4Γ— (strikes 1 and 2)
175
+ cooldown = BASE_COOLDOWN_MS * Math.pow(4, ks.strikes - 1);
176
+ const secs = Math.round(cooldown / 1000);
177
+ log(`[key-rotator] ${p.name}: ...${key.slice(-6)} strike ${ks.strikes}/${MAX_STRIKES} β€” backoff ${secs}s`);
178
+ }
179
+
180
+ ks.blacklistedUntil = Date.now() + cooldown;
181
+ }
182
+
183
+ /**
184
+ * Called on any 2xx/3xx response β€” resets the key's strike counter.
185
+ */
186
+ function recordSuccess(p, key) {
187
+ const ks = p.keyState.get(key);
188
+ if (ks && ks.strikes > 0) {
189
+ ks.strikes = 0;
190
+ log(`[key-rotator] ${p.name}: ...${key.slice(-6)} recovered β€” strikes reset`);
191
+ }
192
+ }
193
+
194
+ // ─── Round-robin selection ────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Pick the next active key using round-robin.
198
+ *
199
+ * FIX (idx drift): idx advances by 1 per CALL, not per skip.
200
+ * We scan up to `total` positions from the current idx to find an active key.
201
+ * The found key's position becomes the new baseline for the next call.
202
+ *
203
+ * Example with 10 keys where k3–k7 are blacklisted:
204
+ * call 1: start=0 β†’ picks k0, next start=1
205
+ * call 2: start=1 β†’ picks k1, next start=2
206
+ * call 3: start=2 β†’ scans k2β†’active, picks k2, next start=3
207
+ * call 4: start=3 β†’ scans k3(skip)…k7(skip)β†’k8 active, picks k8, next start=9
208
+ * call 5: start=9 β†’ picks k9, next start=0
209
+ * Every active key gets equal share; blacklisted keys are cleanly skipped.
210
+ */
211
+ function nextKey(p) {
212
+ if (!p || !p.keys.length) return null;
213
+
214
+ const total = p.keys.length;
215
+
216
+ for (let offset = 0; offset < total; offset++) {
217
+ const i = (p.idx + offset) % total;
218
+ const key = p.keys[i];
219
+ if (isActive(p, key)) {
220
+ p.idx = (i + 1) % total; // next call starts AFTER the key we just picked
221
+ return key;
222
+ }
223
+ }
224
+
225
+ // All keys are sitting out β€” pick the one closest to recovering
226
+ warn(`[key-rotator] ${p.name}: all ${total} key(s) suspended β€” using soonest-recovering key`);
227
+ let best = p.keys[0], bestExpiry = Infinity;
228
+ for (const k of p.keys) {
229
+ const exp = p.keyState.get(k)?.blacklistedUntil ?? 0;
230
+ if (exp < bestExpiry) { best = k; bestExpiry = exp; }
231
+ }
232
+ return best;
233
+ }
234
+
235
+ // ─── Auth header injection ────────────────────────────────────────────────────
236
 
237
  function resolveHostname(urlLike) {
238
  try {
239
  const u =
240
+ typeof urlLike === 'string' ? new URL(urlLike)
241
+ : urlLike instanceof URL ? urlLike
242
+ : urlLike && typeof urlLike.url === 'string' ? new URL(urlLike.url)
243
+ : urlLike && typeof urlLike.href === 'string' ? new URL(urlLike.href)
244
+ : urlLike && typeof urlLike.hostname === 'string' ? urlLike
245
  : null;
246
  return u ? u.hostname : null;
247
+ } catch { return null; }
 
 
248
  }
249
 
250
  function matchProvider(hostname) {
 
252
  return providerState.find(p => p.hostname.test(hostname)) || null;
253
  }
254
 
 
 
 
 
 
 
 
255
  function setAuthHeader(headers, key) {
256
  if (!key) return headers;
257
+ const val = `Bearer ${key}`;
 
258
  if (typeof Headers !== 'undefined' && headers instanceof Headers) {
259
+ headers.set('authorization', val); return headers;
 
260
  }
261
  if (Array.isArray(headers)) {
262
+ return [...headers.filter(([k]) => String(k).toLowerCase() !== 'authorization'), ['authorization', val]];
 
 
263
  }
264
+ if (headers && typeof headers === 'object') return { ...headers, authorization: val };
265
+ return { authorization: val };
266
+ }
267
+
268
+ function handleStatus(p, key, status) {
269
+ if (!p || !key) return;
270
+ if (status === 429 || status === 402) {
271
+ recordFailure(p, key);
272
+ } else if (status >= 200 && status < 400) {
273
+ recordSuccess(p, key);
274
  }
 
275
  }
276
 
277
  // ─── Patch globalThis.fetch ───────────────────────────────────────────────────
278
 
279
  function patchFetch() {
280
  if (typeof globalThis.fetch !== 'function') return;
281
+ const orig = globalThis.fetch.bind(globalThis);
 
282
 
283
  globalThis.fetch = async function patchedFetch(input, init = {}) {
284
+ let usedKey = null, usedProvider = null;
 
 
 
 
285
 
286
+ try {
287
+ const urlLike = typeof input === 'string' || input instanceof URL
288
+ ? input
289
+ : (input && typeof input.url === 'string' ? input.url : null);
290
+ const provider = matchProvider(resolveHostname(urlLike));
291
 
292
+ if (provider) {
293
  const key = nextKey(provider);
294
  if (key) {
295
+ usedKey = key; usedProvider = provider;
296
  if (provider.queryParam) {
 
297
  const url = new URL(typeof input === 'string' ? input : input.url);
298
  url.searchParams.set('key', key);
299
  if (typeof input === 'string') {
300
  input = url.toString();
301
  } else {
302
+ init = { method:input.method, headers:input.headers, body:input.body,
303
+ mode:input.mode, credentials:input.credentials, cache:input.cache,
304
+ redirect:input.redirect, referrer:input.referrer,
305
+ integrity:input.integrity, ...init };
 
 
 
 
 
 
 
 
 
 
306
  input = url.toString();
307
  }
308
  } else {
309
+ init = { ...init, headers: setAuthHeader(init.headers || (input && input.headers) || undefined, key) };
 
 
 
 
 
310
  }
311
  }
312
  }
313
+ } catch (err) { warn('[key-rotator] fetch patch error:', err?.message || err); }
 
 
314
 
315
+ let response;
316
+ try { response = await orig(input, init); }
317
+ catch (err) { throw err; }
318
+
319
+ try { handleStatus(usedProvider, usedKey, response.status); } catch (_) {}
320
+ return response;
321
  };
322
  }
323
 
324
  // ─── Patch node:http / node:https ────────────────────────────────────────────
325
 
326
  function patchHttpModule(mod) {
327
+ const orig = mod.request;
328
 
329
  mod.request = function patchedRequest(...args) {
330
+ let usedKey = null, usedProvider = null;
331
+
332
  try {
333
  const options = args[0];
334
+ const provider = matchProvider(resolveHostname(options));
 
335
 
336
  if (provider) {
337
  const key = nextKey(provider);
338
  if (key) {
339
+ usedKey = key; usedProvider = provider;
340
  if (provider.queryParam) {
341
+ const u = new URL(String(
342
+ typeof options === 'string' || options instanceof URL
343
+ ? options
344
+ : `https://${options.hostname}${options.path || '/'}`
345
+ ));
346
  u.searchParams.set('key', key);
347
  args[0] = typeof options === 'object' && !(options instanceof URL)
348
+ ? { ...options, path:`${u.pathname}${u.search}` }
349
  : u.toString();
350
  } else if (typeof options === 'string' || options instanceof URL) {
 
 
351
  const u = new URL(String(options));
352
+ const extra = (args[1] && typeof args[1] === 'object' && typeof args[1].on !== 'function') ? args[1] : {};
353
+ args[0] = { protocol:u.protocol, hostname:u.hostname, port:u.port,
354
+ path:`${u.pathname}${u.search}`, ...extra,
355
+ headers:setAuthHeader(extra.headers, key) };
 
 
 
 
 
356
  } else if (options && typeof options === 'object') {
357
+ args[0] = { ...options, headers:setAuthHeader(options.headers, key) };
358
  }
359
  }
360
  }
361
+ } catch (err) { warn('[key-rotator] http patch error:', err?.message || err); }
362
+
363
+ const req = orig.apply(mod, args);
364
 
365
+ // Intercept response to track 429/success
366
+ if (usedProvider && usedKey) {
367
+ const _emit = req.emit.bind(req);
368
+ req.emit = function (event, ...rest) {
369
+ if (event === 'response') {
370
+ const res = rest[0];
371
+ try { handleStatus(usedProvider, usedKey, res?.statusCode); } catch (_) {}
372
+ }
373
+ return _emit(event, ...rest);
374
+ };
375
+ }
376
+ return req;
377
  };
378
  }
379
 
380
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
381
 
382
  patchFetch();
383
  patchHttpModule(http);
384
  patchHttpModule(https);
385
 
386
+ log(`[key-rotator] loaded β€” cooldown base:${BASE_COOLDOWN_MS/1000}s max-strikes:${MAX_STRIKES} perm-suspend:24h`);