Spaces:
Running
Running
Anurag commited on
Refactor key rotator and merge kimi-coding with moonshot
Browse filesRefactor multi-provider key rotator to improve key management and error handling. Merge kimi-coding and moonshot providers due to shared hostname.
- 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 |
-
*
|
| 7 |
-
*
|
|
|
|
|
|
|
|
|
|
| 8 |
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 216 |
-
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
const providerState = PROVIDERS.map(p => {
|
| 226 |
-
const
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 244 |
-
|
| 245 |
-
}
|
| 246 |
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
});
|
| 249 |
|
| 250 |
-
//
|
| 251 |
const fallbackCount = providerState.filter(p => {
|
| 252 |
-
const
|
| 253 |
-
|
| 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 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
function resolveHostname(urlLike) {
|
| 267 |
try {
|
| 268 |
const u =
|
| 269 |
-
typeof urlLike === 'string'
|
| 270 |
-
: urlLike instanceof URL
|
| 271 |
-
: urlLike && typeof urlLike.url === 'string'
|
| 272 |
-
: urlLike && typeof urlLike.href === 'string'
|
| 273 |
-
: urlLike && typeof urlLike.hostname === 'string'
|
| 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
|
| 296 |
-
|
| 297 |
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
| 298 |
-
headers.set('authorization',
|
| 299 |
-
return headers;
|
| 300 |
}
|
| 301 |
if (Array.isArray(headers)) {
|
| 302 |
-
|
| 303 |
-
out.push(['authorization', authValue]);
|
| 304 |
-
return out;
|
| 305 |
}
|
| 306 |
-
if (headers && typeof headers === 'object') {
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 321 |
-
const urlLike =
|
| 322 |
-
typeof input === 'string' || input instanceof URL
|
| 323 |
-
? input
|
| 324 |
-
: input && typeof input.url === 'string' ? input.url : null;
|
| 325 |
|
| 326 |
-
|
| 327 |
-
const
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
|
| 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 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
};
|
| 371 |
}
|
| 372 |
|
| 373 |
// βββ Patch node:http / node:https ββββββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
|
| 375 |
function patchHttpModule(mod) {
|
| 376 |
-
const
|
| 377 |
|
| 378 |
mod.request = function patchedRequest(...args) {
|
|
|
|
|
|
|
| 379 |
try {
|
| 380 |
const options = args[0];
|
| 381 |
-
const
|
| 382 |
-
const provider = matchProvider(hostname);
|
| 383 |
|
| 384 |
if (provider) {
|
| 385 |
const key = nextKey(provider);
|
| 386 |
if (key) {
|
|
|
|
| 387 |
if (provider.queryParam) {
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
| 390 |
u.searchParams.set('key', key);
|
| 391 |
args[0] = typeof options === 'object' && !(options instanceof URL)
|
| 392 |
-
? { ...options, path:
|
| 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
|
| 399 |
-
args[0] = {
|
| 400 |
-
|
| 401 |
-
|
| 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:
|
| 409 |
}
|
| 410 |
}
|
| 411 |
}
|
| 412 |
-
} catch (err) {
|
| 413 |
-
|
| 414 |
-
|
| 415 |
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
};
|
| 418 |
}
|
| 419 |
|
| 420 |
-
// βββ
|
| 421 |
|
| 422 |
patchFetch();
|
| 423 |
patchHttpModule(http);
|
| 424 |
patchHttpModule(https);
|
| 425 |
|
| 426 |
-
log(
|
|
|
|
| 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`);
|