Spaces:
Running
Running
File size: 18,674 Bytes
e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc 62f946b e6ee6f1 cfd6981 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc 49cc5fe e6ee6f1 233a0dc e6ee6f1 49cc5fe 233a0dc 49cc5fe 233a0dc 62f946b 233a0dc 49cc5fe 233a0dc e6ee6f1 233a0dc 49cc5fe 233a0dc 49cc5fe 233a0dc 62f946b 233a0dc 49cc5fe 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc b81080d c8a2bdb 233a0dc c8a2bdb b81080d 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc b81080d 233a0dc b81080d 233a0dc b81080d e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc e6ee6f1 233a0dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 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 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 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 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 | 'use strict';
/**
* Multi-provider API key rotator for OpenClaw/HuggingClaw
* --------------------------------------------------------
* - Round-robin rotation per provider
* - 429/402 β exponential backoff blacklist per key
* - After MAX_STRIKES consecutive failures β permanent session blacklist
* - Successful response β strikes reset
* - 10+ keys handled correctly (idx tracks only active keys, no drift)
*
* Env vars:
* KEY_BLACKLIST_COOLDOWN_MS base backoff ms (default 60 000)
* KEY_MAX_STRIKES failures before perm (default 3)
* LLM_API_KEY_FALLBACK_ENABLED true/false (default true)
*/
const http = require('node:http');
const https = require('node:https');
const log = (...a) => console.error(...a);
const warn = (...a) => console.warn(...a);
// βββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const BASE_COOLDOWN_MS = Math.max(
1000,
parseInt(process.env.KEY_BLACKLIST_COOLDOWN_MS || '', 10) || 60_000,
);
const MAX_STRIKES = Math.max(
1,
parseInt(process.env.KEY_MAX_STRIKES || '', 10) || 3,
);
// Permanently blacklisted keys retry after this long (default 24 h).
// "Permanent" just means very long β avoids truly forever loops on app restart.
const PERM_BLACKLIST_MS = 24 * 60 * 60 * 1000;
// βββ Provider definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
const PROVIDERS = [
{ name:'anthropic', hostname:/(?:^|\.)api\.anthropic\.com$/i, envPlural:'ANTHROPIC_API_KEYS', envSingular:'ANTHROPIC_API_KEY' },
{ name:'openai', hostname:/(?:^|\.)api\.openai\.com$/i, envPlural:'OPENAI_API_KEYS', envSingular:'OPENAI_API_KEY' },
{ name:'gemini', hostname:/(?:^|\.)(?:generativelanguage\.googleapis\.com|aiplatform\.googleapis\.com)$/i,
envPlural:'GEMINI_API_KEYS', envSingular:'GEMINI_API_KEY', queryParam:true },
{ name:'deepseek', hostname:/(?:^|\.)api\.deepseek\.com$/i, envPlural:'DEEPSEEK_API_KEYS', envSingular:'DEEPSEEK_API_KEY' },
{ name:'openrouter', hostname:/(?:^|\.)openrouter\.ai$/i, envPlural:'OPENROUTER_API_KEYS', envSingular:'OPENROUTER_API_KEY' },
{ name:'kilocode', hostname:/(?:^|\.)kilocode\.ai$/i, envPlural:'KILOCODE_API_KEYS', envSingular:'KILOCODE_API_KEY' },
{ name:'opencode', hostname:/(?:^|\.)opencode\.ai$/i, envPlural:'OPENCODE_API_KEYS', envSingular:'OPENCODE_API_KEY' },
{ name:'zai', hostname:/(?:^|\.)(?:z\.ai|open\.bigmodel\.cn)$/i, envPlural:'ZAI_API_KEYS', envSingular:'ZAI_API_KEY' },
// FIX: kimi-coding aur moonshot ek hi hostname share karte hain (api.moonshot.cn).
// Purani file mein dono alag entries thi β find() hamesha kimi-coding pick karta tha,
// MOONSHOT_API_KEYS kabhi use nahi hoti. Ab merged entry: dono pools combine honge.
{ name:'kimi-moonshot',hostname:/(?:^|\.)api\.moonshot\.cn$/i, envPlural:'KIMI_API_KEYS', envSingular:'KIMI_API_KEY',
_extraPlural:'MOONSHOT_API_KEYS', _extraSingular:'MOONSHOT_API_KEY' },
{ name:'minimax', hostname:/(?:^|\.)api\.minimax\.chat$/i, envPlural:'MINIMAX_API_KEYS', envSingular:'MINIMAX_API_KEY' },
{ name:'xiaomi', hostname:/(?:^|\.)api\.xiaomi\.com$/i, envPlural:'XIAOMI_API_KEYS', envSingular:'XIAOMI_API_KEY' },
{ name:'volcengine', hostname:/(?:^|\.)(?:ark\.cn-beijing\.volces\.com|volcengineapi\.com)$/i,
envPlural:'VOLCANO_ENGINE_API_KEYS', envSingular:'VOLCANO_ENGINE_API_KEY' },
{ name:'byteplus', hostname:/(?:^|\.)maas-api\.ml-platform-cn-beijing\.byteplus\.com$/i,
envPlural:'BYTEPLUS_API_KEYS', envSingular:'BYTEPLUS_API_KEY' },
{ name:'mistral', hostname:/(?:^|\.)api\.mistral\.ai$/i, envPlural:'MISTRAL_API_KEYS', envSingular:'MISTRAL_API_KEY' },
{ name:'xai', hostname:/(?:^|\.)api\.x\.ai$/i, envPlural:'XAI_API_KEYS', envSingular:'XAI_API_KEY' },
{ name:'nvidia', hostname:/(?:^|\.)(?:integrate\.api\.nvidia\.com|api\.nvidia\.com)$/i,
envPlural:'NVIDIA_API_KEYS', envSingular:'NVIDIA_API_KEY' },
{ name:'groq', hostname:/(?:^|\.)api\.groq\.com$/i, envPlural:'GROQ_API_KEYS', envSingular:'GROQ_API_KEY' },
{ name:'cohere', hostname:/(?:^|\.)api\.cohere\.(?:ai|com)$/i, envPlural:'COHERE_API_KEYS', envSingular:'COHERE_API_KEY' },
{ name:'together', hostname:/(?:^|\.)api\.together\.(?:xyz|ai)$/i, envPlural:'TOGETHER_API_KEYS', envSingular:'TOGETHER_API_KEY' },
{ name:'cerebras', hostname:/(?:^|\.)api\.cerebras\.ai$/i, envPlural:'CEREBRAS_API_KEYS', envSingular:'CEREBRAS_API_KEY' },
{ name:'huggingface', hostname:/(?:^|\.)(?:api-inference\.huggingface\.co|router\.huggingface\.co|huggingface\.co)$/i,
envPlural:'HUGGINGFACE_HUB_TOKENS', envSingular:'HUGGINGFACE_HUB_TOKEN' },
{ name:'venice', hostname:/(?:^|\.)api\.venice\.ai$/i, envPlural:'VENICE_API_KEYS', envSingular:'VENICE_API_KEY' },
{ name:'github-copilot',hostname:/(?:^|\.)api\.githubcopilot\.com$/i, envPlural:'COPILOT_GITHUB_TOKENS', envSingular:'COPILOT_GITHUB_TOKEN' },
{ name:'qianfan', hostname:/(?:^|\.)(?:aip|qianfan)\.baidubce\.com$/i, envPlural:'QIANFAN_API_KEYS', envSingular:'QIANFAN_API_KEY' },
{ name:'modelstudio', hostname:/(?:^|\.)dashscope\.aliyuncs\.com$/i, envPlural:'MODELSTUDIO_API_KEYS', envSingular:'MODELSTUDIO_API_KEY' },
{ name:'synthetic', hostname:/(?:^|\.)synthetic\.local$/i, envPlural:'SYNTHETIC_API_KEYS', envSingular:'SYNTHETIC_API_KEY' },
];
// βββ Key loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function normalizeKeys(...inputs) {
const seen = new Set(), out = [];
for (const input of inputs)
for (const k of String(input || '').split(',').map(s => s.trim()).filter(Boolean))
if (!seen.has(k)) { seen.add(k); out.push(k); }
return out;
}
// Per-key state: { strikes, blacklistedUntil }
// strikes β consecutive 429/402 count; resets on success
// blacklistedUntil β epoch ms; 0 = active
function makeKeyState() { return { strikes: 0, blacklistedUntil: 0 }; }
const providerState = PROVIDERS.map(p => {
const llmFallbackEnabled = !/^(0|false|no|off)$/.test(
String(process.env.LLM_API_KEY_FALLBACK_ENABLED || '').trim().toLowerCase(),
);
const extraKeys = (p._extraPlural || p._extraSingular)
? normalizeKeys(process.env[p._extraPlural || ''] || '', process.env[p._extraSingular || ''] || '')
: [];
const dedicatedKeys = normalizeKeys(
process.env[p.envPlural] || '',
process.env[p.envSingular] || '',
...extraKeys,
);
const hasDedicated = dedicatedKeys.length > 0;
const keys = hasDedicated
? dedicatedKeys
: (llmFallbackEnabled ? normalizeKeys(process.env.LLM_API_KEY || '') : []);
if (hasDedicated)
log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
else if (!keys.length)
warn(`[key-rotator] No keys for provider "${p.name}"`);
// keyState: Map<keyString, {strikes, blacklistedUntil}>
const keyState = new Map(keys.map(k => [k, makeKeyState()]));
// FIX: idx tracks position in the ACTIVE (non-permanently-removed) pool.
// We never remove keys from the array β we just skip blacklisted ones.
// idx advances only when a key is ACTUALLY picked (no drift for skipped keys).
return { ...p, keys, keyState, idx: 0 };
});
// LLM_API_KEY fallback summary
const fallbackCount = providerState.filter(p => {
const ded = normalizeKeys(process.env[p.envPlural] || '', process.env[p.envSingular] || '');
return ded.length === 0 && p.keys.length > 0;
}).length;
if (fallbackCount > 0)
log(`[key-rotator] ${fallbackCount} provider(s) using LLM_API_KEY fallback`);
// βββ Per-key state helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Is this key currently sitting out?
* Also auto-clears expired blacklists so the key re-enters the pool silently.
*/
function isActive(p, key) {
const ks = p.keyState.get(key);
if (!ks) return true; // unknown key β treat as active
if (ks.blacklistedUntil === 0) return true; // not blacklisted
if (Date.now() >= ks.blacklistedUntil) {
ks.blacklistedUntil = 0; // expired β back in pool
log(`[key-rotator] ${p.name}: ...${key.slice(-6)} back in pool`);
return true;
}
return false;
}
/**
* Called when a key gets a 429/402 response.
*
* Strike logic:
* strike 1 β BASE_COOLDOWN_MS (e.g. 60 s β probably rate-limit)
* strike 2 β BASE_COOLDOWN_MS Γ 4 (240 s)
* strike 3 β PERM_BLACKLIST_MS (24 h β treat as quota exhausted, skip all day)
*
* A successful response resets strikes so a key that was temporarily
* rate-limited and recovered is treated as fresh again.
*/
function recordFailure(p, key) {
let ks = p.keyState.get(key);
if (!ks) { ks = makeKeyState(); p.keyState.set(key, ks); }
ks.strikes = Math.min(ks.strikes + 1, MAX_STRIKES);
let cooldown;
if (ks.strikes >= MAX_STRIKES) {
cooldown = PERM_BLACKLIST_MS;
warn(`[key-rotator] ${p.name}: ...${key.slice(-6)} reached ${MAX_STRIKES} strikes β suspended for 24 h (quota likely exhausted)`);
} else {
// Exponential: 1Γ β 4Γ (strikes 1 and 2)
cooldown = BASE_COOLDOWN_MS * Math.pow(4, ks.strikes - 1);
const secs = Math.round(cooldown / 1000);
log(`[key-rotator] ${p.name}: ...${key.slice(-6)} strike ${ks.strikes}/${MAX_STRIKES} β backoff ${secs}s`);
}
ks.blacklistedUntil = Date.now() + cooldown;
}
/**
* Called on any 2xx/3xx response β resets the key's strike counter.
*/
function recordSuccess(p, key) {
const ks = p.keyState.get(key);
if (ks && ks.strikes > 0) {
ks.strikes = 0;
log(`[key-rotator] ${p.name}: ...${key.slice(-6)} recovered β strikes reset`);
}
}
// βββ Round-robin selection ββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Pick the next active key using round-robin.
*
* FIX (idx drift): idx advances by 1 per CALL, not per skip.
* We scan up to `total` positions from the current idx to find an active key.
* The found key's position becomes the new baseline for the next call.
*
* Example with 10 keys where k3βk7 are blacklisted:
* call 1: start=0 β picks k0, next start=1
* call 2: start=1 β picks k1, next start=2
* call 3: start=2 β scans k2βactive, picks k2, next start=3
* call 4: start=3 β scans k3(skip)β¦k7(skip)βk8 active, picks k8, next start=9
* call 5: start=9 β picks k9, next start=0
* Every active key gets equal share; blacklisted keys are cleanly skipped.
*/
function nextKey(p) {
if (!p || !p.keys.length) return null;
const total = p.keys.length;
for (let offset = 0; offset < total; offset++) {
const i = (p.idx + offset) % total;
const key = p.keys[i];
if (isActive(p, key)) {
p.idx = (i + 1) % total; // next call starts AFTER the key we just picked
return key;
}
}
// All keys are sitting out β pick the one closest to recovering
warn(`[key-rotator] ${p.name}: all ${total} key(s) suspended β using soonest-recovering key`);
let best = p.keys[0], bestExpiry = Infinity;
for (const k of p.keys) {
const exp = p.keyState.get(k)?.blacklistedUntil ?? 0;
if (exp < bestExpiry) { best = k; bestExpiry = exp; }
}
return best;
}
// βββ Auth header injection ββββββββββββββββββββββββββββββββββββββββββββββββββββ
function resolveHostname(urlLike) {
try {
const u =
typeof urlLike === 'string' ? new URL(urlLike)
: urlLike instanceof URL ? urlLike
: urlLike && typeof urlLike.url === 'string' ? new URL(urlLike.url)
: urlLike && typeof urlLike.href === 'string' ? new URL(urlLike.href)
: urlLike && typeof urlLike.hostname === 'string' ? urlLike
: null;
return u ? u.hostname : null;
} catch { return null; }
}
function matchProvider(hostname) {
if (!hostname) return null;
return providerState.find(p => p.hostname.test(hostname)) || null;
}
function setAuthHeader(headers, key) {
if (!key) return headers;
const val = `Bearer ${key}`;
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
headers.set('authorization', val); return headers;
}
if (Array.isArray(headers)) {
return [...headers.filter(([k]) => String(k).toLowerCase() !== 'authorization'), ['authorization', val]];
}
if (headers && typeof headers === 'object') return { ...headers, authorization: val };
return { authorization: val };
}
function handleStatus(p, key, status) {
if (!p || !key) return;
if (status === 429 || status === 402) {
recordFailure(p, key);
} else if (status >= 200 && status < 400) {
recordSuccess(p, key);
}
}
// βββ Patch globalThis.fetch βββββββββββββββββββββββββββββββββββββββββββββββββββ
function patchFetch() {
if (typeof globalThis.fetch !== 'function') return;
const orig = globalThis.fetch.bind(globalThis);
globalThis.fetch = async function patchedFetch(input, init = {}) {
let usedKey = null, usedProvider = null;
try {
const urlLike = typeof input === 'string' || input instanceof URL
? input
: (input && typeof input.url === 'string' ? input.url : null);
const provider = matchProvider(resolveHostname(urlLike));
if (provider) {
const key = nextKey(provider);
if (key) {
usedKey = key; usedProvider = provider;
if (provider.queryParam) {
const url = new URL(typeof input === 'string' ? input : input.url);
url.searchParams.set('key', key);
if (typeof input === 'string') {
input = url.toString();
} else {
init = { method:input.method, headers:input.headers, body:input.body,
mode:input.mode, credentials:input.credentials, cache:input.cache,
redirect:input.redirect, referrer:input.referrer,
integrity:input.integrity, ...init };
input = url.toString();
}
} else {
init = { ...init, headers: setAuthHeader(init.headers || (input && input.headers) || undefined, key) };
}
}
}
} catch (err) { warn('[key-rotator] fetch patch error:', err?.message || err); }
let response;
try { response = await orig(input, init); }
catch (err) { throw err; }
try { handleStatus(usedProvider, usedKey, response.status); } catch (_) {}
return response;
};
}
// βββ Patch node:http / node:https ββββββββββββββββββββββββββββββββββββββββββββ
function patchHttpModule(mod) {
const orig = mod.request;
mod.request = function patchedRequest(...args) {
let usedKey = null, usedProvider = null;
try {
const options = args[0];
const provider = matchProvider(resolveHostname(options));
if (provider) {
const key = nextKey(provider);
if (key) {
usedKey = key; usedProvider = provider;
if (provider.queryParam) {
const u = new URL(String(
typeof options === 'string' || options instanceof URL
? options
: `https://${options.hostname}${options.path || '/'}`
));
u.searchParams.set('key', key);
args[0] = typeof options === 'object' && !(options instanceof URL)
? { ...options, path:`${u.pathname}${u.search}` }
: u.toString();
} else if (typeof options === 'string' || options instanceof URL) {
const u = new URL(String(options));
const extra = (args[1] && typeof args[1] === 'object' && typeof args[1].on !== 'function') ? args[1] : {};
args[0] = { protocol:u.protocol, hostname:u.hostname, port:u.port,
path:`${u.pathname}${u.search}`, ...extra,
headers:setAuthHeader(extra.headers, key) };
} else if (options && typeof options === 'object') {
args[0] = { ...options, headers:setAuthHeader(options.headers, key) };
}
}
}
} catch (err) { warn('[key-rotator] http patch error:', err?.message || err); }
const req = orig.apply(mod, args);
// Intercept response to track 429/success
if (usedProvider && usedKey) {
const _emit = req.emit.bind(req);
req.emit = function (event, ...rest) {
if (event === 'response') {
const res = rest[0];
try { handleStatus(usedProvider, usedKey, res?.statusCode); } catch (_) {}
}
return _emit(event, ...rest);
};
}
return req;
};
}
// βββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
patchFetch();
patchHttpModule(http);
patchHttpModule(https);
log(`[key-rotator] loaded β cooldown base:${BASE_COOLDOWN_MS/1000}s max-strikes:${MAX_STRIKES} perm-suspend:24h`);
|