Spaces:
Running
Running
Commit Β·
e6ee6f1
1
Parent(s): 332c458
update rotator and startup files
Browse files- Dockerfile +2 -2
- multi-provider-key-rotator.cjs +318 -0
- nvidia-key-rotator.cjs +0 -185
- start.sh +1 -1
- test-rotator.mjs +124 -0
Dockerfile
CHANGED
|
@@ -72,8 +72,8 @@ COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
|
|
| 72 |
COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
|
| 73 |
COPY --chown=1000:1000 openclaw-sync.py /home/node/app/openclaw-sync.py
|
| 74 |
RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py
|
| 75 |
-
COPY --chown=1000:1000
|
| 76 |
-
RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py /home/node/app/
|
| 77 |
|
| 78 |
USER node
|
| 79 |
|
|
|
|
| 72 |
COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
|
| 73 |
COPY --chown=1000:1000 openclaw-sync.py /home/node/app/openclaw-sync.py
|
| 74 |
RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py
|
| 75 |
+
COPY --chown=1000:1000 multi-provider-key-rotator.cjs /home/node/app/multi-provider-key-rotator.cjs
|
| 76 |
+
RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py /home/node/app/multi-provider-key-rotator.cjs
|
| 77 |
|
| 78 |
USER node
|
| 79 |
|
multi-provider-key-rotator.cjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use strict';
|
| 2 |
+
|
| 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, then 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 |
+
// βββ Provider definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
//
|
| 24 |
+
// hostname β regex tested against the request hostname (case-insensitive)
|
| 25 |
+
// envPlural β env var that holds a comma-separated key pool (preferred)
|
| 26 |
+
// envSingular β env var that holds a single key (fallback)
|
| 27 |
+
//
|
| 28 |
+
// LLM_API_KEY is the final fallback for every provider.
|
| 29 |
+
//
|
| 30 |
+
const PROVIDERS = [
|
| 31 |
+
{
|
| 32 |
+
name: 'anthropic',
|
| 33 |
+
hostname: /(?:^|\.)api\.anthropic\.com$/i,
|
| 34 |
+
envPlural: 'ANTHROPIC_API_KEYS',
|
| 35 |
+
envSingular:'ANTHROPIC_API_KEY',
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
name: 'openai',
|
| 39 |
+
hostname: /(?:^|\.)api\.openai\.com$/i,
|
| 40 |
+
envPlural: 'OPENAI_API_KEYS',
|
| 41 |
+
envSingular:'OPENAI_API_KEY',
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
name: 'gemini',
|
| 45 |
+
// Gemini uses generativelanguage API; also covers aiplatform
|
| 46 |
+
hostname: /(?:^|\.)(?:generativelanguage\.googleapis\.com|aiplatform\.googleapis\.com)$/i,
|
| 47 |
+
envPlural: 'GEMINI_API_KEYS',
|
| 48 |
+
envSingular:'GEMINI_API_KEY',
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
name: 'deepseek',
|
| 52 |
+
hostname: /(?:^|\.)api\.deepseek\.com$/i,
|
| 53 |
+
envPlural: 'DEEPSEEK_API_KEYS',
|
| 54 |
+
envSingular:'DEEPSEEK_API_KEY',
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
name: 'openrouter',
|
| 58 |
+
hostname: /(?:^|\.)openrouter\.ai$/i,
|
| 59 |
+
envPlural: 'OPENROUTER_API_KEYS',
|
| 60 |
+
envSingular:'OPENROUTER_API_KEY',
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
name: 'kilocode',
|
| 64 |
+
hostname: /(?:^|\.)kilocode\.ai$/i,
|
| 65 |
+
envPlural: 'KILOCODE_API_KEYS',
|
| 66 |
+
envSingular:'KILOCODE_API_KEY',
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
name: 'opencode',
|
| 70 |
+
hostname: /(?:^|\.)opencode\.ai$/i,
|
| 71 |
+
envPlural: 'OPENCODE_API_KEYS',
|
| 72 |
+
envSingular:'OPENCODE_API_KEY',
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
name: 'zai',
|
| 76 |
+
// Z.ai / GLM β both domains normalised to "zai" in OpenClaw
|
| 77 |
+
hostname: /(?:^|\.)(?:z\.ai|open\.bigmodel\.cn)$/i,
|
| 78 |
+
envPlural: 'ZAI_API_KEYS',
|
| 79 |
+
envSingular:'ZAI_API_KEY',
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
name: 'moonshot',
|
| 83 |
+
hostname: /(?:^|\.)api\.moonshot\.cn$/i,
|
| 84 |
+
envPlural: 'MOONSHOT_API_KEYS',
|
| 85 |
+
envSingular:'MOONSHOT_API_KEY',
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
name: 'minimax',
|
| 89 |
+
hostname: /(?:^|\.)api\.minimax\.chat$/i,
|
| 90 |
+
envPlural: 'MINIMAX_API_KEYS',
|
| 91 |
+
envSingular:'MINIMAX_API_KEY',
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
name: 'xiaomi',
|
| 95 |
+
// MiMo β update hostname if Xiaomi publishes an official domain
|
| 96 |
+
hostname: /(?:^|\.)api\.xiaomi\.com$/i,
|
| 97 |
+
envPlural: 'XIAOMI_API_KEYS',
|
| 98 |
+
envSingular:'XIAOMI_API_KEY',
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
name: 'volcengine',
|
| 102 |
+
// Volcengine / Doubao
|
| 103 |
+
hostname: /(?:^|\.)(?:ark\.cn-beijing\.volces\.com|volcengineapi\.com)$/i,
|
| 104 |
+
envPlural: 'VOLCANO_ENGINE_API_KEYS',
|
| 105 |
+
envSingular:'VOLCANO_ENGINE_API_KEY',
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
name: 'byteplus',
|
| 109 |
+
hostname: /(?:^|\.)maas-api\.ml-platform-cn-beijing\.byteplus\.com$/i,
|
| 110 |
+
envPlural: 'BYTEPLUS_API_KEYS',
|
| 111 |
+
envSingular:'BYTEPLUS_API_KEY',
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
name: 'mistral',
|
| 115 |
+
hostname: /(?:^|\.)api\.mistral\.ai$/i,
|
| 116 |
+
envPlural: 'MISTRAL_API_KEYS',
|
| 117 |
+
envSingular:'MISTRAL_API_KEY',
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
name: 'xai',
|
| 121 |
+
hostname: /(?:^|\.)api\.x\.ai$/i,
|
| 122 |
+
envPlural: 'XAI_API_KEYS',
|
| 123 |
+
envSingular:'XAI_API_KEY',
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
name: 'nvidia',
|
| 127 |
+
hostname: /(?:^|\.)(?:integrate\.api\.nvidia\.com|api\.nvidia\.com)$/i,
|
| 128 |
+
envPlural: 'NVIDIA_API_KEYS',
|
| 129 |
+
envSingular:'NVIDIA_API_KEY',
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
name: 'groq',
|
| 133 |
+
hostname: /(?:^|\.)api\.groq\.com$/i,
|
| 134 |
+
envPlural: 'GROQ_API_KEYS',
|
| 135 |
+
envSingular:'GROQ_API_KEY',
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
name: 'cohere',
|
| 139 |
+
hostname: /(?:^|\.)api\.cohere\.(?:ai|com)$/i,
|
| 140 |
+
envPlural: 'COHERE_API_KEYS',
|
| 141 |
+
envSingular:'COHERE_API_KEY',
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
name: 'together',
|
| 145 |
+
hostname: /(?:^|\.)api\.together\.(?:xyz|ai)$/i,
|
| 146 |
+
envPlural: 'TOGETHER_API_KEYS',
|
| 147 |
+
envSingular:'TOGETHER_API_KEY',
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
name: 'cerebras',
|
| 151 |
+
hostname: /(?:^|\.)api\.cerebras\.ai$/i,
|
| 152 |
+
envPlural: 'CEREBRAS_API_KEYS',
|
| 153 |
+
envSingular:'CEREBRAS_API_KEY',
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
name: 'huggingface',
|
| 157 |
+
hostname: /(?:^|\.)(?:api-inference\.huggingface\.co|router\.huggingface\.co|huggingface\.co)$/i,
|
| 158 |
+
envPlural: 'HUGGINGFACE_HUB_TOKENS', // plural variant
|
| 159 |
+
envSingular:'HUGGINGFACE_HUB_TOKEN',
|
| 160 |
+
},
|
| 161 |
+
];
|
| 162 |
+
|
| 163 |
+
// βββ Key loading βββββββοΏ½οΏ½βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
function normalizeKeys(...inputs) {
|
| 166 |
+
const seen = new Set();
|
| 167 |
+
const out = [];
|
| 168 |
+
for (const input of inputs) {
|
| 169 |
+
for (const k of String(input || '').split(',').map(s => s.trim()).filter(Boolean)) {
|
| 170 |
+
if (!seen.has(k)) { seen.add(k); out.push(k); }
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
return out;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Build per-provider key pools + rotation indices
|
| 177 |
+
const providerState = PROVIDERS.map(p => {
|
| 178 |
+
const keys = normalizeKeys(
|
| 179 |
+
process.env[p.envPlural] || '',
|
| 180 |
+
process.env[p.envSingular] || '',
|
| 181 |
+
process.env.LLM_API_KEY || '',
|
| 182 |
+
);
|
| 183 |
+
if (!keys.length) {
|
| 184 |
+
console.warn(`[key-rotator] No keys for provider "${p.name}"`);
|
| 185 |
+
} else {
|
| 186 |
+
console.log(`[key-rotator] ${p.name}: ${keys.length} key${keys.length === 1 ? '' : 's'}`);
|
| 187 |
+
}
|
| 188 |
+
return { ...p, keys, idx: 0 };
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
// βββ Runtime helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
|
| 193 |
+
function resolveHostname(urlLike) {
|
| 194 |
+
try {
|
| 195 |
+
const u =
|
| 196 |
+
typeof urlLike === 'string' ? new URL(urlLike)
|
| 197 |
+
: urlLike instanceof URL ? urlLike
|
| 198 |
+
: urlLike && typeof urlLike.url === 'string' ? new URL(urlLike.url)
|
| 199 |
+
: urlLike && typeof urlLike.href === 'string' ? new URL(urlLike.href)
|
| 200 |
+
: urlLike && typeof urlLike.hostname === 'string' ? urlLike
|
| 201 |
+
: null;
|
| 202 |
+
return u ? u.hostname : null;
|
| 203 |
+
} catch {
|
| 204 |
+
return null;
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function matchProvider(hostname) {
|
| 209 |
+
if (!hostname) return null;
|
| 210 |
+
return providerState.find(p => p.hostname.test(hostname)) || null;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function nextKey(provider) {
|
| 214 |
+
if (!provider || !provider.keys.length) return null;
|
| 215 |
+
const key = provider.keys[provider.idx % provider.keys.length];
|
| 216 |
+
provider.idx = (provider.idx + 1) % provider.keys.length;
|
| 217 |
+
return key;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function setAuthHeader(headers, key) {
|
| 221 |
+
if (!key) return headers;
|
| 222 |
+
const authValue = `Bearer ${key}`;
|
| 223 |
+
|
| 224 |
+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
| 225 |
+
headers.set('authorization', authValue);
|
| 226 |
+
return headers;
|
| 227 |
+
}
|
| 228 |
+
if (Array.isArray(headers)) {
|
| 229 |
+
const out = headers.filter(([k]) => String(k).toLowerCase() !== 'authorization');
|
| 230 |
+
out.push(['authorization', authValue]);
|
| 231 |
+
return out;
|
| 232 |
+
}
|
| 233 |
+
if (headers && typeof headers === 'object') {
|
| 234 |
+
return { ...headers, authorization: authValue };
|
| 235 |
+
}
|
| 236 |
+
return { authorization: authValue };
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// βββ Patch globalThis.fetch βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
|
| 241 |
+
function patchFetch() {
|
| 242 |
+
if (typeof globalThis.fetch !== 'function') return;
|
| 243 |
+
|
| 244 |
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
| 245 |
+
|
| 246 |
+
globalThis.fetch = async function patchedFetch(input, init = {}) {
|
| 247 |
+
try {
|
| 248 |
+
const urlLike =
|
| 249 |
+
typeof input === 'string' || input instanceof URL
|
| 250 |
+
? input
|
| 251 |
+
: input && typeof input.url === 'string' ? input.url : null;
|
| 252 |
+
|
| 253 |
+
const hostname = resolveHostname(urlLike);
|
| 254 |
+
const provider = matchProvider(hostname);
|
| 255 |
+
|
| 256 |
+
if (provider) {
|
| 257 |
+
const key = nextKey(provider);
|
| 258 |
+
if (key) {
|
| 259 |
+
const headers = init.headers || (input && input.headers) || undefined;
|
| 260 |
+
const patchedHeaders = setAuthHeader(headers, key);
|
| 261 |
+
init = { ...init, headers: patchedHeaders };
|
| 262 |
+
|
| 263 |
+
if (input && typeof input === 'object' && !(input instanceof URL) && input.headers) {
|
| 264 |
+
try { input = new Request(input, { headers: patchedHeaders }); } catch { /* noop */ }
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
} catch (err) {
|
| 269 |
+
console.warn('[key-rotator] fetch patch error:', err?.message || err);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
return originalFetch(input, init);
|
| 273 |
+
};
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// βββ Patch node:http / node:https ββββββββββββββββββββββββββββββββββββββββββββ
|
| 277 |
+
|
| 278 |
+
function patchHttpModule(mod) {
|
| 279 |
+
const originalRequest = mod.request;
|
| 280 |
+
|
| 281 |
+
mod.request = function patchedRequest(...args) {
|
| 282 |
+
try {
|
| 283 |
+
const options = args[0];
|
| 284 |
+
const hostname = resolveHostname(options);
|
| 285 |
+
const provider = matchProvider(hostname);
|
| 286 |
+
|
| 287 |
+
if (provider) {
|
| 288 |
+
const key = nextKey(provider);
|
| 289 |
+
if (key) {
|
| 290 |
+
if (typeof options === 'string' || options instanceof URL) {
|
| 291 |
+
const u = new URL(String(options));
|
| 292 |
+
args[0] = {
|
| 293 |
+
protocol: u.protocol,
|
| 294 |
+
hostname: u.hostname,
|
| 295 |
+
port: u.port,
|
| 296 |
+
path: `${u.pathname}${u.search}`,
|
| 297 |
+
headers: { authorization: `Bearer ${key}` },
|
| 298 |
+
};
|
| 299 |
+
} else if (options && typeof options === 'object') {
|
| 300 |
+
args[0] = { ...options, headers: setAuthHeader(options.headers, key) };
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
} catch (err) {
|
| 305 |
+
console.warn('[key-rotator] http patch error:', err?.message || err);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
return originalRequest.apply(mod, args);
|
| 309 |
+
};
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// βββ Apply patches ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 313 |
+
|
| 314 |
+
patchFetch();
|
| 315 |
+
patchHttpModule(http);
|
| 316 |
+
patchHttpModule(https);
|
| 317 |
+
|
| 318 |
+
console.log('[key-rotator] loaded β all providers active');
|
nvidia-key-rotator.cjs
DELETED
|
@@ -1,185 +0,0 @@
|
|
| 1 |
-
'use strict';
|
| 2 |
-
|
| 3 |
-
/**
|
| 4 |
-
* NVIDIA API key rotator for OpenClaw/HuggingClaw
|
| 5 |
-
* ------------------------------------------------
|
| 6 |
-
* - Supports comma-separated keys in NVIDIA_API_KEYS
|
| 7 |
-
* - Falls back to NVIDIA_API_KEY, then LLM_API_KEY
|
| 8 |
-
* - Rotates keys on every NVIDIA request
|
| 9 |
-
* - Patches fetch + http/https request so most callers are covered
|
| 10 |
-
*/
|
| 11 |
-
|
| 12 |
-
const http = require('node:http');
|
| 13 |
-
const https = require('node:https');
|
| 14 |
-
|
| 15 |
-
const NVIDIA_HOST_RE = /(^|\.)((integrate\.api\.nvidia\.com)|(api\.nvidia\.com))$/i;
|
| 16 |
-
|
| 17 |
-
function normalizeKeys(input) {
|
| 18 |
-
return String(input || '')
|
| 19 |
-
.split(',')
|
| 20 |
-
.map((s) => s.trim())
|
| 21 |
-
.filter(Boolean);
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
const keys = Array.from(
|
| 25 |
-
new Set(
|
| 26 |
-
normalizeKeys(process.env.NVIDIA_API_KEYS)
|
| 27 |
-
.concat(normalizeKeys(process.env.NVIDIA_API_KEY))
|
| 28 |
-
.concat(normalizeKeys(process.env.LLM_API_KEY))
|
| 29 |
-
)
|
| 30 |
-
);
|
| 31 |
-
|
| 32 |
-
if (!keys.length) {
|
| 33 |
-
console.warn('[nvidia-key-rotator] No NVIDIA keys found');
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
let idx = 0;
|
| 37 |
-
|
| 38 |
-
function nextKey() {
|
| 39 |
-
if (!keys.length) return null;
|
| 40 |
-
const key = keys[idx % keys.length];
|
| 41 |
-
idx = (idx + 1) % keys.length;
|
| 42 |
-
return key;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
function isNvidiaUrl(urlLike) {
|
| 46 |
-
try {
|
| 47 |
-
const u = typeof urlLike === 'string'
|
| 48 |
-
? new URL(urlLike)
|
| 49 |
-
: urlLike instanceof URL
|
| 50 |
-
? urlLike
|
| 51 |
-
: urlLike && typeof urlLike.url === 'string'
|
| 52 |
-
? new URL(urlLike.url)
|
| 53 |
-
: null;
|
| 54 |
-
|
| 55 |
-
if (!u) return false;
|
| 56 |
-
return NVIDIA_HOST_RE.test(u.hostname);
|
| 57 |
-
} catch {
|
| 58 |
-
return false;
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
function setAuthHeader(headers, key) {
|
| 63 |
-
if (!key) return headers;
|
| 64 |
-
|
| 65 |
-
const authValue = `Bearer ${key}`;
|
| 66 |
-
|
| 67 |
-
// Headers instance
|
| 68 |
-
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
| 69 |
-
headers.set('authorization', authValue);
|
| 70 |
-
return headers;
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
// Array of tuples
|
| 74 |
-
if (Array.isArray(headers)) {
|
| 75 |
-
const out = headers.filter(([k]) => String(k).toLowerCase() !== 'authorization');
|
| 76 |
-
out.push(['authorization', authValue]);
|
| 77 |
-
return out;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
// Plain object
|
| 81 |
-
if (headers && typeof headers === 'object') {
|
| 82 |
-
return {
|
| 83 |
-
...headers,
|
| 84 |
-
authorization: authValue,
|
| 85 |
-
};
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
return { authorization: authValue };
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
function patchFetch() {
|
| 92 |
-
if (typeof globalThis.fetch !== 'function') return;
|
| 93 |
-
|
| 94 |
-
const originalFetch = globalThis.fetch.bind(globalThis);
|
| 95 |
-
|
| 96 |
-
globalThis.fetch = async function patchedFetch(input, init = {}) {
|
| 97 |
-
try {
|
| 98 |
-
const urlLike =
|
| 99 |
-
typeof input === 'string' || input instanceof URL
|
| 100 |
-
? input
|
| 101 |
-
: input && typeof input.url === 'string'
|
| 102 |
-
? input.url
|
| 103 |
-
: null;
|
| 104 |
-
|
| 105 |
-
if (urlLike && isNvidiaUrl(urlLike)) {
|
| 106 |
-
const key = nextKey();
|
| 107 |
-
if (key) {
|
| 108 |
-
const headers = init.headers || (input && input.headers) || undefined;
|
| 109 |
-
const patchedHeaders = setAuthHeader(headers, key);
|
| 110 |
-
|
| 111 |
-
if (init && typeof init === 'object') {
|
| 112 |
-
init = { ...init, headers: patchedHeaders };
|
| 113 |
-
} else {
|
| 114 |
-
init = { headers: patchedHeaders };
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
if (input && typeof input === 'object' && !(input instanceof URL) && input.headers) {
|
| 118 |
-
try {
|
| 119 |
-
input = new Request(input, { headers: patchedHeaders });
|
| 120 |
-
} catch {
|
| 121 |
-
// ignore and let fetch handle original input
|
| 122 |
-
}
|
| 123 |
-
}
|
| 124 |
-
}
|
| 125 |
-
}
|
| 126 |
-
} catch (err) {
|
| 127 |
-
console.warn('[nvidia-key-rotator] fetch patch error:', err?.message || err);
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
return originalFetch(input, init);
|
| 131 |
-
};
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
function patchHttpModule(mod) {
|
| 135 |
-
const originalRequest = mod.request;
|
| 136 |
-
|
| 137 |
-
mod.request = function patchedRequest(...args) {
|
| 138 |
-
try {
|
| 139 |
-
let options = args[0];
|
| 140 |
-
|
| 141 |
-
const urlLike =
|
| 142 |
-
typeof options === 'string' || options instanceof URL
|
| 143 |
-
? options
|
| 144 |
-
: options && typeof options === 'object' && typeof options.href === 'string'
|
| 145 |
-
? options.href
|
| 146 |
-
: null;
|
| 147 |
-
|
| 148 |
-
if (urlLike && isNvidiaUrl(urlLike)) {
|
| 149 |
-
const key = nextKey();
|
| 150 |
-
if (key) {
|
| 151 |
-
if (typeof options === 'string' || options instanceof URL) {
|
| 152 |
-
const u = new URL(String(options));
|
| 153 |
-
u.username = '';
|
| 154 |
-
u.password = '';
|
| 155 |
-
args[0] = {
|
| 156 |
-
protocol: u.protocol,
|
| 157 |
-
hostname: u.hostname,
|
| 158 |
-
port: u.port,
|
| 159 |
-
path: `${u.pathname}${u.search}`,
|
| 160 |
-
headers: { authorization: `Bearer ${key}` },
|
| 161 |
-
};
|
| 162 |
-
} else if (options && typeof options === 'object') {
|
| 163 |
-
const headers = setAuthHeader(options.headers, key);
|
| 164 |
-
args[0] = {
|
| 165 |
-
...options,
|
| 166 |
-
headers,
|
| 167 |
-
};
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
}
|
| 171 |
-
} catch (err) {
|
| 172 |
-
console.warn('[nvidia-key-rotator] http patch error:', err?.message || err);
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
return originalRequest.apply(mod, args);
|
| 176 |
-
};
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
patchFetch();
|
| 180 |
-
patchHttpModule(http);
|
| 181 |
-
patchHttpModule(https);
|
| 182 |
-
|
| 183 |
-
console.log(
|
| 184 |
-
`[nvidia-key-rotator] loaded (${keys.length} key${keys.length === 1 ? '' : 's'})`
|
| 185 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
start.sh
CHANGED
|
@@ -426,7 +426,7 @@ chmod 600 /home/node/.openclaw/openclaw.json
|
|
| 426 |
|
| 427 |
# ββ Enable Gateway Preload Fixes ββ
|
| 428 |
# This preload script keeps iframe embedding working on HF Spaces.
|
| 429 |
-
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs --require /home/node/app/
|
| 430 |
|
| 431 |
# ββ Startup Summary ββ
|
| 432 |
echo ""
|
|
|
|
| 426 |
|
| 427 |
# ββ Enable Gateway Preload Fixes ββ
|
| 428 |
# This preload script keeps iframe embedding working on HF Spaces.
|
| 429 |
+
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs --require /home/node/app/multi-provider-key-rotator.cjs"
|
| 430 |
|
| 431 |
# ββ Startup Summary ββ
|
| 432 |
echo ""
|
test-rotator.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createRequire } from 'module';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { fileURLToPath } from 'url';
|
| 4 |
+
const require = createRequire(import.meta.url);
|
| 5 |
+
const G = s => `\x1b[32m${s}\x1b[0m`;
|
| 6 |
+
const R = s => `\x1b[31m${s}\x1b[0m`;
|
| 7 |
+
const Y = s => `\x1b[33m${s}\x1b[0m`;
|
| 8 |
+
const B = s => `\x1b[1m${s}\x1b[0m`;
|
| 9 |
+
|
| 10 |
+
Object.assign(process.env, {
|
| 11 |
+
ANTHROPIC_API_KEYS:'ant-k1,ant-k2,ant-k3', OPENAI_API_KEYS:'oai-k1,oai-k2,oai-k3',
|
| 12 |
+
GEMINI_API_KEYS:'gem-k1,gem-k2,gem-k3', DEEPSEEK_API_KEYS:'dsk-k1,dsk-k2,dsk-k3',
|
| 13 |
+
OPENROUTER_API_KEYS:'ort-k1,ort-k2,ort-k3', KILOCODE_API_KEYS:'kil-k1,kil-k2,kil-k3',
|
| 14 |
+
OPENCODE_API_KEYS:'ocd-k1,ocd-k2,ocd-k3', ZAI_API_KEYS:'zai-k1,zai-k2,zai-k3',
|
| 15 |
+
MOONSHOT_API_KEYS:'msn-k1,msn-k2,msn-k3', MINIMAX_API_KEYS:'mmx-k1,mmx-k2,mmx-k3',
|
| 16 |
+
XIAOMI_API_KEYS:'xmi-k1,xmi-k2,xmi-k3', VOLCANO_ENGINE_API_KEYS:'vlc-k1,vlc-k2,vlc-k3',
|
| 17 |
+
BYTEPLUS_API_KEYS:'btp-k1,btp-k2,btp-k3', MISTRAL_API_KEYS:'mst-k1,mst-k2,mst-k3',
|
| 18 |
+
XAI_API_KEYS:'xai-k1,xai-k2,xai-k3', NVIDIA_API_KEYS:'nv-k1,nv-k2,nv-k3',
|
| 19 |
+
GROQ_API_KEYS:'grq-k1,grq-k2,grq-k3', COHERE_API_KEYS:'coh-k1,coh-k2,coh-k3',
|
| 20 |
+
TOGETHER_API_KEYS:'tgt-k1,tgt-k2,tgt-k3', CEREBRAS_API_KEYS:'crb-k1,crb-k2,crb-k3',
|
| 21 |
+
HUGGINGFACE_HUB_TOKENS:'hf-k1,hf-k2,hf-k3',
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// Mock fetch BEFORE loading rotator (rotator captures this as "originalFetch")
|
| 25 |
+
const fetchLog = [];
|
| 26 |
+
globalThis.fetch = async function mockFetch(input, init = {}) {
|
| 27 |
+
const url = typeof input === 'string' ? input : input?.url ?? '?';
|
| 28 |
+
const h = init?.headers ?? {};
|
| 29 |
+
const auth = typeof h.get === 'function' ? h.get('authorization') : (h.authorization ?? null);
|
| 30 |
+
fetchLog.push({ url, auth });
|
| 31 |
+
return new Response('{}', { status: 200 });
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// Mock http/https BEFORE loading rotator
|
| 35 |
+
const http = require('http');
|
| 36 |
+
const https = require('https');
|
| 37 |
+
const httpLog = [];
|
| 38 |
+
for (const mod of [http, https]) {
|
| 39 |
+
mod.request = function mockReq(...args) {
|
| 40 |
+
const opts = args[0];
|
| 41 |
+
const hostname = typeof opts === 'string' ? new URL(opts).hostname : opts?.hostname ?? '?';
|
| 42 |
+
const auth = opts?.headers?.authorization ?? null;
|
| 43 |
+
httpLog.push({ hostname, auth });
|
| 44 |
+
return { on() { return this; }, end() {}, write() {} };
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Load rotator
|
| 49 |
+
console.log(B('\nββ Loading rotator βββββββββββββββββββββββββββββββββββββ\n'));
|
| 50 |
+
const origLog = console.log; const ll = [];
|
| 51 |
+
console.log = (...a) => ll.push(a.join(' '));
|
| 52 |
+
require('./multi-provider-key-rotator.cjs');
|
| 53 |
+
console.log = origLog;
|
| 54 |
+
ll.forEach(l => console.log(' ' + l));
|
| 55 |
+
|
| 56 |
+
const TESTS = [
|
| 57 |
+
['api.anthropic.com','ant-','Anthropic'],
|
| 58 |
+
['api.openai.com','oai-','OpenAI'],
|
| 59 |
+
['generativelanguage.googleapis.com','gem-','Gemini'],
|
| 60 |
+
['api.deepseek.com','dsk-','DeepSeek'],
|
| 61 |
+
['openrouter.ai','ort-','OpenRouter'],
|
| 62 |
+
['kilocode.ai','kil-','KiloCode'],
|
| 63 |
+
['opencode.ai','ocd-','OpenCode'],
|
| 64 |
+
['open.bigmodel.cn','zai-','Z.ai / GLM'],
|
| 65 |
+
['api.moonshot.cn','msn-','Moonshot'],
|
| 66 |
+
['api.minimax.chat','mmx-','MiniMax'],
|
| 67 |
+
['api.xiaomi.com','xmi-','Xiaomi'],
|
| 68 |
+
['ark.cn-beijing.volces.com','vlc-','Volcengine'],
|
| 69 |
+
['maas-api.ml-platform-cn-beijing.byteplus.com','btp-','BytePlus'],
|
| 70 |
+
['api.mistral.ai','mst-','Mistral'],
|
| 71 |
+
['api.x.ai','xai-','xAI / Grok'],
|
| 72 |
+
['integrate.api.nvidia.com','nv-','NVIDIA (integrate)'],
|
| 73 |
+
['api.nvidia.com','nv-','NVIDIA (api)'],
|
| 74 |
+
['api.groq.com','grq-','Groq'],
|
| 75 |
+
['api.cohere.com','coh-','Cohere'],
|
| 76 |
+
['api.cohere.ai','coh-','Cohere (alt)'],
|
| 77 |
+
['api.together.xyz','tgt-','Together'],
|
| 78 |
+
['api.cerebras.ai','crb-','Cerebras'],
|
| 79 |
+
['api-inference.huggingface.co','hf-','HuggingFace'],
|
| 80 |
+
];
|
| 81 |
+
|
| 82 |
+
console.log(B('\nββ fetch() β 3 requests each (rotation check) βββββββββ\n'));
|
| 83 |
+
let passed = 0, failed = 0;
|
| 84 |
+
|
| 85 |
+
for (const [hostname, prefix, label] of TESTS) {
|
| 86 |
+
fetchLog.length = 0;
|
| 87 |
+
for (let i = 0; i < 3; i++) await globalThis.fetch(`https://${hostname}/v1/messages`, {});
|
| 88 |
+
const keys = fetchLog.map(f => f.auth?.replace('Bearer ', '') ?? null);
|
| 89 |
+
const ok = keys.every(k => k?.startsWith(prefix)) && keys[0] !== keys[1] && keys[1] !== keys[2];
|
| 90 |
+
if (ok) { passed++; console.log(` ${G('β')} ${label.padEnd(24)} ${keys.join(' β ')}`); }
|
| 91 |
+
else { failed++; console.log(` ${R('β')} ${label.padEnd(24)} got: ${JSON.stringify(keys)}`); }
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Unknown host β no header
|
| 95 |
+
fetchLog.length = 0;
|
| 96 |
+
await globalThis.fetch('https://example.com/test', {});
|
| 97 |
+
const unk = fetchLog[0]?.auth ?? null;
|
| 98 |
+
if (!unk) { passed++; console.log(`\n ${G('β')} ${'Unknown host'.padEnd(24)} correctly skipped`); }
|
| 99 |
+
else { failed++; console.log(`\n ${R('β')} ${'Unknown host'.padEnd(24)} wrongly injected: ${unk}`); }
|
| 100 |
+
|
| 101 |
+
console.log(B('\nββ http.request() β direct options object ββββββββββββββ\n'));
|
| 102 |
+
const HTTP_TESTS = [
|
| 103 |
+
['api.anthropic.com','ant-','Anthropic'],
|
| 104 |
+
['api.openai.com','oai-','OpenAI'],
|
| 105 |
+
['api.mistral.ai','mst-','Mistral'],
|
| 106 |
+
['integrate.api.nvidia.com','nv-','NVIDIA'],
|
| 107 |
+
['api.groq.com','grq-','Groq'],
|
| 108 |
+
['api.x.ai','xai-','xAI'],
|
| 109 |
+
];
|
| 110 |
+
for (const [hostname, prefix, label] of HTTP_TESTS) {
|
| 111 |
+
httpLog.length = 0;
|
| 112 |
+
http.request({ hostname, path: '/v1', headers: {} });
|
| 113 |
+
http.request({ hostname, path: '/v1', headers: {} });
|
| 114 |
+
const keys = httpLog.map(e => e.auth?.replace('Bearer ', '') ?? null);
|
| 115 |
+
const ok = keys.every(k => k?.startsWith(prefix)) && keys[0] !== keys[1];
|
| 116 |
+
if (ok) { passed++; console.log(` ${G('β')} ${label.padEnd(24)} ${keys.join(' β ')}`); }
|
| 117 |
+
else { failed++; console.log(` ${R('β')} ${label.padEnd(24)} got: ${JSON.stringify(keys)}`); }
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
console.log(B('\nββββββββββββββββββββββββββββββββββββββββββββββββββββββββ'));
|
| 121 |
+
const total = passed + failed;
|
| 122 |
+
console.log(` ${G(passed+'/'+total+' passed')} ${failed ? R(failed+' failed') : ''}`);
|
| 123 |
+
if (failed === 0) console.log(` ${G('β Rotator sahi kaam kar raha hai β deploy karo!')}\n`);
|
| 124 |
+
else console.log(` ${R('β Issues hain β upar dekho.')}\n`);
|