Somrat Sorkar commited on
Commit
818a8a5
Β·
unverified Β·
2 Parent(s): 15eb45d3083806

Merge pull request #11 from anurag162008/main

Browse files

feat: add multi-provider API key rotation and update docs

Files changed (7) hide show
  1. .env.example +86 -1
  2. .gitignore +44 -0
  3. Dockerfile +2 -0
  4. iframe-fix.cjs +4 -0
  5. multi-provider-key-rotator.cjs +318 -0
  6. openclaw-sync.py +44 -9
  7. start.sh +89 -4
.env.example CHANGED
@@ -5,7 +5,7 @@
5
  # For local development: cp .env.example .env && nano .env
6
 
7
  # ── REQUIRED: Core Configuration ──
8
- # [REQUIRED] LLM provider API key
9
  # - Anthropic: sk-ant-v0-...
10
  # - OpenAI: sk-...
11
  # - Google: AIzaSy...
@@ -124,6 +124,91 @@ LLM_API_KEY=your_api_key_here
124
  # Or any other OpenClaw-supported provider (format: provider/model-name)
125
  LLM_MODEL=anthropic/claude-sonnet-4-5
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # Optional: custom OpenAI-compatible provider
128
  # Only use this if you want to register your own endpoint at startup.
129
  # Leave all of these empty unless you need a custom provider.
 
5
  # For local development: cp .env.example .env && nano .env
6
 
7
  # ── REQUIRED: Core Configuration ──
8
+ # [REQUIRED] Primary LLM provider API key (for the provider in LLM_MODEL)
9
  # - Anthropic: sk-ant-v0-...
10
  # - OpenAI: sk-...
11
  # - Google: AIzaSy...
 
124
  # Or any other OpenClaw-supported provider (format: provider/model-name)
125
  LLM_MODEL=anthropic/claude-sonnet-4-5
126
 
127
+ # ════════════════════════════════════════════════════════════════
128
+ # πŸ”‘ API KEY ROTATION (per-provider key pools)
129
+ # ════════════════════════════════════════════════════════════════
130
+ # Every provider supports a comma-separated key pool.
131
+ # Keys are rotated round-robin on every request β€” useful when you
132
+ # have multiple accounts or want to spread rate-limit quota.
133
+ #
134
+ # Pattern: <PROVIDER>_API_KEYS=key1,key2,key3
135
+ # Fallback order: plural pool β†’ singular key β†’ LLM_API_KEY
136
+ #
137
+ # Uncomment and fill in only the providers you use:
138
+ #
139
+ # ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2,sk-ant-key3
140
+ # OPENAI_API_KEYS=sk-key1,sk-key2,sk-key3
141
+ # GEMINI_API_KEYS=AIzaSy-key1,AIzaSy-key2
142
+ # DEEPSEEK_API_KEYS=key1,key2
143
+ # OPENROUTER_API_KEYS=sk-or-key1,sk-or-key2
144
+ # KILOCODE_API_KEYS=key1,key2
145
+ # OPENCODE_API_KEYS=key1,key2
146
+ # ZAI_API_KEYS=key1,key2
147
+ # MOONSHOT_API_KEYS=key1,key2
148
+ # MINIMAX_API_KEYS=key1,key2
149
+ # XIAOMI_API_KEYS=key1,key2
150
+ # VOLCANO_ENGINE_API_KEYS=key1,key2
151
+ # BYTEPLUS_API_KEYS=key1,key2
152
+ # MISTRAL_API_KEYS=key1,key2
153
+ # XAI_API_KEYS=key1,key2
154
+ # NVIDIA_API_KEYS=key1,key2,key3
155
+ # GROQ_API_KEYS=gsk-key1,gsk-key2,gsk-key3
156
+ # COHERE_API_KEYS=key1,key2
157
+ # TOGETHER_API_KEYS=key1,key2
158
+ # CEREBRAS_API_KEYS=key1,key2
159
+ # HUGGINGFACE_HUB_TOKENS=hf_key1,hf_key2
160
+
161
+ # ════════════════════════════════════════════════════════════════
162
+ # 🌐 MULTI-PROVIDER SETUP (use multiple models simultaneously)
163
+ # ════════════════════════════════════════════════════════════════
164
+ # LLM_MODEL sets your DEFAULT model. But you can activate multiple
165
+ # providers at once β€” OpenClaw auto-discovers any provider whose
166
+ # API key is present in the environment.
167
+ #
168
+ # HOW IT WORKS:
169
+ # 1. LLM_MODEL + LLM_API_KEY β†’ sets your default model & exports
170
+ # the matching provider key automatically (e.g. ANTHROPIC_API_KEY)
171
+ # 2. Any additional provider key you set directly (e.g. OPENAI_API_KEY)
172
+ # is also picked up by OpenClaw β†’ that provider's models become
173
+ # available in the Control UI for manual selection.
174
+ # 3. Rotation pools (*_API_KEYS) work for every active provider
175
+ # independently and in parallel.
176
+ #
177
+ # EXAMPLE β€” default Anthropic, also use OpenAI and Groq:
178
+ #
179
+ # LLM_MODEL=anthropic/claude-sonnet-4-6 # default
180
+ # LLM_API_KEY=sk-ant-xxx # β†’ auto-sets ANTHROPIC_API_KEY
181
+ #
182
+ # OPENAI_API_KEY=sk-oai-xxx # activates OpenAI models
183
+ # OPENAI_API_KEYS=sk-oai-k1,sk-oai-k2 # optional: rotation pool
184
+ #
185
+ # GROQ_API_KEY=gsk-xxx # activates Groq models
186
+ # GROQ_API_KEYS=gsk-k1,gsk-k2,gsk-k3 # optional: rotation pool
187
+ #
188
+ # EXAMPLE β€” OpenRouter only (easiest: one key, 300+ models):
189
+ #
190
+ # LLM_MODEL=openrouter/anthropic/claude-sonnet-4-6
191
+ # LLM_API_KEY=sk-or-v1-xxx
192
+ # # Switch between any of 300+ models in Control UI β€” no extra keys needed
193
+ #
194
+ # Set any of these directly as HF Space Secrets (not via LLM_API_KEY):
195
+ # ANTHROPIC_API_KEY=sk-ant-xxx
196
+ # OPENAI_API_KEY=sk-oai-xxx
197
+ # GEMINI_API_KEY=AIzaSy-xxx
198
+ # DEEPSEEK_API_KEY=xxx
199
+ # GROQ_API_KEY=gsk-xxx
200
+ # MISTRAL_API_KEY=xxx
201
+ # XAI_API_KEY=xxx
202
+ # NVIDIA_API_KEY=nvapi-xxx
203
+ # COHERE_API_KEY=xxx
204
+ # TOGETHER_API_KEY=xxx
205
+ # CEREBRAS_API_KEY=xxx
206
+ # MOONSHOT_API_KEY=xxx
207
+ # OPENROUTER_API_KEY=sk-or-v1-xxx
208
+ # HUGGINGFACE_HUB_TOKEN=hf_xxx
209
+
210
+ # ════════════════════════════════════════════════════════════════
211
+
212
  # Optional: custom OpenAI-compatible provider
213
  # Only use this if you want to register your own endpoint at startup.
214
  # Leave all of these empty unless you need a custom provider.
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # personal workflows
2
+ .github/workflows/
3
+ .git*
4
+ # env/secrets
5
+ .env
6
+ .env.*
7
+ *.pem
8
+ *.key
9
+
10
+ # node
11
+ node_modules/
12
+ npm-debug.log*
13
+ yarn-debug.log*
14
+ pnpm-debug.log*
15
+
16
+ # python
17
+ __pycache__/
18
+ *.pyc
19
+ .venv/
20
+ venv/
21
+
22
+ # logs
23
+ logs/
24
+ *.log
25
+
26
+ # local configs
27
+ .vscode/
28
+ .idea/
29
+
30
+ # build/cache
31
+ dist/
32
+ build/
33
+ .cache/
34
+
35
+ # temp
36
+ tmp/
37
+ temp/
38
+
39
+ # os junk
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # gitignore itself
44
+ .gitignore
Dockerfile CHANGED
@@ -72,6 +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
 
76
  USER node
77
 
 
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
 
iframe-fix.cjs CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  /**
2
  * iframe-fix.cjs β€” Node.js preload script
3
  *
 
1
+ process.on('uncaughtException', function(err) {
2
+ if (err.code === 'EPIPE') return;
3
+ throw err;
4
+ });
5
  /**
6
  * iframe-fix.cjs β€” Node.js preload script
7
  *
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');
openclaw-sync.py CHANGED
@@ -51,7 +51,7 @@ EXCLUDED_SYNC_DIRS = {
51
  }
52
  MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
53
 
54
- STATE_DIR = WORKSPACE / ".huggingclaw-state"
55
  OPENCLAW_STATE_BACKUP_DIR = STATE_DIR / "openclaw"
56
  EXCLUDED_STATE_NAMES = {
57
  "workspace",
@@ -87,20 +87,33 @@ def count_files(path: Path) -> int:
87
  def snapshot_state_into_workspace() -> None:
88
  try:
89
  STATE_DIR.mkdir(parents=True, exist_ok=True)
90
- if OPENCLAW_STATE_BACKUP_DIR.exists():
91
- shutil.rmtree(OPENCLAW_STATE_BACKUP_DIR, ignore_errors=True)
92
- OPENCLAW_STATE_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
 
 
 
 
93
 
94
  for source_path in OPENCLAW_HOME.iterdir():
95
  if source_path.name in EXCLUDED_STATE_NAMES:
96
  continue
97
 
98
- backup_path = OPENCLAW_STATE_BACKUP_DIR / source_path.name
99
  if source_path.is_dir():
100
  shutil.copytree(source_path, backup_path)
101
  elif source_path.is_file():
102
  shutil.copy2(source_path, backup_path)
 
 
 
 
 
103
  except Exception as exc:
 
 
 
 
104
  print(f"Warning: could not snapshot OpenClaw state: {exc}")
105
 
106
  try:
@@ -135,6 +148,23 @@ def snapshot_state_into_workspace() -> None:
135
 
136
  def restore_embedded_state() -> None:
137
  state_backup_root = STATE_DIR / "openclaw"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  if state_backup_root.is_dir():
139
  for source_path in state_backup_root.iterdir():
140
  name = source_path.name
@@ -398,12 +428,18 @@ def loop() -> int:
398
  print(f"Workspace sync error: {exc}")
399
  return 1
400
 
401
- last_fingerprint = fingerprint_dir(WORKSPACE)
402
- last_marker = metadata_marker(WORKSPACE)
403
-
404
  time.sleep(INITIAL_DELAY)
405
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
406
 
 
 
 
 
 
 
 
 
 
407
  while not STOP_EVENT.is_set():
408
  try:
409
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
@@ -416,7 +452,6 @@ def loop() -> int:
416
 
417
  return 0
418
 
419
-
420
  def main() -> int:
421
  WORKSPACE.mkdir(parents=True, exist_ok=True)
422
 
 
51
  }
52
  MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
53
 
54
+ STATE_DIR = WORKSPACE / "huggingclaw-state"
55
  OPENCLAW_STATE_BACKUP_DIR = STATE_DIR / "openclaw"
56
  EXCLUDED_STATE_NAMES = {
57
  "workspace",
 
87
  def snapshot_state_into_workspace() -> None:
88
  try:
89
  STATE_DIR.mkdir(parents=True, exist_ok=True)
90
+ # Atomic snapshot: copy to a staging dir first, then rename.
91
+ # This prevents a half-written (or empty) backup if we crash mid-copy,
92
+ # which would otherwise be uploaded and overwrite the real HF backup.
93
+ staging_dir = STATE_DIR / ".openclaw-staging"
94
+ if staging_dir.exists():
95
+ shutil.rmtree(staging_dir, ignore_errors=True)
96
+ staging_dir.mkdir(parents=True, exist_ok=True)
97
 
98
  for source_path in OPENCLAW_HOME.iterdir():
99
  if source_path.name in EXCLUDED_STATE_NAMES:
100
  continue
101
 
102
+ backup_path = staging_dir / source_path.name
103
  if source_path.is_dir():
104
  shutil.copytree(source_path, backup_path)
105
  elif source_path.is_file():
106
  shutil.copy2(source_path, backup_path)
107
+
108
+ # Atomically swap staging β†’ real backup dir
109
+ if OPENCLAW_STATE_BACKUP_DIR.exists():
110
+ shutil.rmtree(OPENCLAW_STATE_BACKUP_DIR, ignore_errors=True)
111
+ staging_dir.rename(OPENCLAW_STATE_BACKUP_DIR)
112
  except Exception as exc:
113
+ # Clean up staging on failure so it doesn't interfere next time
114
+ staging_dir = STATE_DIR / ".openclaw-staging"
115
+ if staging_dir.exists():
116
+ shutil.rmtree(staging_dir, ignore_errors=True)
117
  print(f"Warning: could not snapshot OpenClaw state: {exc}")
118
 
119
  try:
 
148
 
149
  def restore_embedded_state() -> None:
150
  state_backup_root = STATE_DIR / "openclaw"
151
+
152
+ # Migration fix: old backups stored state in ".huggingclaw-state/openclaw"
153
+ # (hidden dir). If new path doesn't exist but old hidden path does, use it
154
+ # and migrate it to the new path so future syncs write to the right place.
155
+ if not state_backup_root.is_dir():
156
+ legacy_state = WORKSPACE / ".huggingclaw-state" / "openclaw"
157
+ if legacy_state.is_dir():
158
+ print("Found legacy state backup at .huggingclaw-state/; migrating to huggingclaw-state/...")
159
+ try:
160
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
161
+ shutil.copytree(legacy_state, state_backup_root)
162
+ legacy_root = WORKSPACE / ".huggingclaw-state"
163
+ shutil.rmtree(legacy_root, ignore_errors=True)
164
+ print("Legacy state migrated and .huggingclaw-state/ removed.")
165
+ except Exception as exc:
166
+ print(f"Warning: could not migrate legacy state: {exc}")
167
+
168
  if state_backup_root.is_dir():
169
  for source_path in state_backup_root.iterdir():
170
  name = source_path.name
 
428
  print(f"Workspace sync error: {exc}")
429
  return 1
430
 
 
 
 
431
  time.sleep(INITIAL_DELAY)
432
  print(f"Workspace sync started: every {INTERVAL}s -> {repo_id}")
433
 
434
+ # Take a fingerprint of the workspace AS RESTORED (after snapshotting state)
435
+ # so the first loop iteration only uploads if something genuinely changed.
436
+ # Previously this was None, which forced an unconditional upload every restart
437
+ # β€” even when restore had failed silently and the workspace was empty.
438
+ snapshot_state_into_workspace()
439
+ last_fingerprint = fingerprint_dir(WORKSPACE)
440
+ last_marker = metadata_marker(WORKSPACE)
441
+ print("Initial workspace fingerprint captured.")
442
+
443
  while not STOP_EVENT.is_set():
444
  try:
445
  last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
 
452
 
453
  return 0
454
 
 
455
  def main() -> int:
456
  WORKSPACE.mkdir(parents=True, exist_ok=True)
457
 
start.sh CHANGED
@@ -107,7 +107,7 @@ case "$LLM_PROVIDER" in
107
  qianfan) export QIANFAN_API_KEY="$LLM_API_KEY" ;;
108
  # ── Western Providers ──
109
  mistral|mistralai) export MISTRAL_API_KEY="$LLM_API_KEY" ;;
110
- xai|x-ai) export XAI_API_KEY="$LLM_API_KEY" ;;
111
  nvidia) export NVIDIA_API_KEY="$LLM_API_KEY" ;;
112
  cohere) export COHERE_API_KEY="$LLM_API_KEY" ;;
113
  groq) export GROQ_API_KEY="$LLM_API_KEY" ;;
@@ -420,12 +420,39 @@ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
420
  fi
421
 
422
  # Write config
423
- echo "$CONFIG_JSON" > "/home/node/.openclaw/openclaw.json"
424
- chmod 600 /home/node/.openclaw/openclaw.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
  # ── Enable Gateway Preload Fixes ──
427
  # This preload script keeps iframe embedding working on HF Spaces.
428
- export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs"
429
 
430
  # ── Startup Summary ──
431
  echo ""
@@ -497,6 +524,7 @@ warmup_browser() {
497
  ) &
498
  }
499
 
 
500
  # ── Start background services ──
501
  export LLM_MODEL="$LLM_MODEL"
502
  # 10. Start Health Server & Dashboard
@@ -508,6 +536,63 @@ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
508
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
509
  fi
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  # ── Launch gateway ──
512
  echo "Launching OpenClaw gateway on port 7860..."
513
 
 
107
  qianfan) export QIANFAN_API_KEY="$LLM_API_KEY" ;;
108
  # ── Western Providers ──
109
  mistral|mistralai) export MISTRAL_API_KEY="$LLM_API_KEY" ;;
110
+ xai|x-ai) export XAI_API_KEY="$LLM_API_KEY" ;;
111
  nvidia) export NVIDIA_API_KEY="$LLM_API_KEY" ;;
112
  cohere) export COHERE_API_KEY="$LLM_API_KEY" ;;
113
  groq) export GROQ_API_KEY="$LLM_API_KEY" ;;
 
420
  fi
421
 
422
  # Write config
423
+ EXISTING_CONFIG="/home/node/.openclaw/openclaw.json"
424
+ if [ -f "$EXISTING_CONFIG" ]; then
425
+ echo "Restored config found β€” patching required fields only..."
426
+ PATCHED=$(jq \
427
+ --arg token "$GATEWAY_TOKEN" \
428
+ --arg model "$LLM_MODEL" \
429
+ --arg fileLevel "$OPENCLAW_FILE_LOG_LEVEL" \
430
+ --arg consoleLevel "$OPENCLAW_CONSOLE_LOG_LEVEL" \
431
+ --arg consoleStyle "$OPENCLAW_CONSOLE_LOG_STYLE" \
432
+ '.gateway.auth.token = $token
433
+ | .agents.defaults.model = $model
434
+ | .logging.level = $fileLevel
435
+ | .logging.consoleLevel = $consoleLevel
436
+ | .logging.consoleStyle = $consoleStyle' \
437
+ "$EXISTING_CONFIG" 2>/dev/null)
438
+
439
+ if [ -n "$PATCHED" ]; then
440
+ echo "$PATCHED" > "$EXISTING_CONFIG.tmp" \
441
+ && mv "$EXISTING_CONFIG.tmp" "$EXISTING_CONFIG"
442
+ echo "Config patched successfully."
443
+ else
444
+ echo "Patch failed β€” writing fresh config."
445
+ echo "$CONFIG_JSON" > "$EXISTING_CONFIG"
446
+ fi
447
+ else
448
+ echo "No restored config β€” writing fresh config..."
449
+ echo "$CONFIG_JSON" > "$EXISTING_CONFIG"
450
+ fi
451
+ chmod 600 "$EXISTING_CONFIG"
452
 
453
  # ── Enable Gateway Preload Fixes ──
454
  # This preload script keeps iframe embedding working on HF Spaces.
455
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/app/iframe-fix.cjs --require /home/node/app/multi-provider-key-rotator.cjs"
456
 
457
  # ── Startup Summary ──
458
  echo ""
 
524
  ) &
525
  }
526
 
527
+
528
  # ── Start background services ──
529
  export LLM_MODEL="$LLM_MODEL"
530
  # 10. Start Health Server & Dashboard
 
536
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
537
  fi
538
 
539
+ # ── Write shell capture wrappers to .bashrc ──
540
+ STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
541
+ cat > /home/node/.bashrc << 'BASHRC'
542
+ STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
543
+ _hc_append() {
544
+ local line="$*"
545
+ grep -qxF "$line" "$STARTUP_FILE" 2>/dev/null || echo "$line" >> "$STARTUP_FILE"
546
+ }
547
+ apt-get() {
548
+ command apt-get "$@"
549
+ [[ "$1" == "install" ]] && _hc_append "apt-get install -y ${@:2}"
550
+ }
551
+ apt() {
552
+ command apt "$@"
553
+ [[ "$1" == "install" ]] && _hc_append "apt-get install -y ${@:2}"
554
+ }
555
+ pip() { command pip "$@"; [[ "$1" == "install" ]] && _hc_append "pip install ${@:2}"; }
556
+ pip3() { command pip3 "$@"; [[ "$1" == "install" ]] && _hc_append "pip3 install ${@:2}"; }
557
+ npm() { command npm "$@"; [[ "$1" == "install" && "$2" == "-g" ]] && _hc_append "npm install -g ${@:3}"; }
558
+ openclaw() { command openclaw "$@"; [[ "$1" == "plugins" && "$2" == "install" ]] && _hc_append "openclaw plugins install ${@:3}"; }
559
+ BASHRC
560
+ echo "Shell capture wrappers ready."
561
+
562
+ # ── Re-install previously installed plugins ──
563
+ EXISTING_CONFIG="/home/node/.openclaw/openclaw.json"
564
+ if [ -f "$EXISTING_CONFIG" ]; then
565
+ INSTALLS=$(jq -r '.plugins.installs // {} | keys[]' "$EXISTING_CONFIG" 2>/dev/null || echo "")
566
+ if [ -n "$INSTALLS" ]; then
567
+ echo "Re-installing plugins from config..."
568
+ while IFS= read -r pkg; do
569
+ [ -z "$pkg" ] && continue
570
+ # Try short name first, then @openclaw/ prefix
571
+ if openclaw plugins install "$pkg" 2>/dev/null; then
572
+ echo " Installed: $pkg"
573
+ elif openclaw plugins install "@openclaw/$pkg" 2>/dev/null; then
574
+ echo " Installed: @openclaw/$pkg"
575
+ else
576
+ echo " Warning: could not install $pkg"
577
+ fi
578
+ done <<< "$INSTALLS"
579
+ echo "Plugins done."
580
+ fi
581
+ fi
582
+
583
+ # ── Run workspace startup script ──
584
+ STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
585
+ if [ ! -f "$STARTUP_FILE" ]; then
586
+ touch "$STARTUP_FILE"
587
+ chmod +x "$STARTUP_FILE"
588
+ echo "Created workspace/startup.sh"
589
+ fi
590
+ if [ -s "$STARTUP_FILE" ]; then
591
+ echo "Running workspace/startup.sh..."
592
+ bash "$STARTUP_FILE" || echo "Warning: startup.sh had errors, continuing..."
593
+ echo "Startup script complete."
594
+ fi
595
+
596
  # ── Launch gateway ──
597
  echo "Launching OpenClaw gateway on port 7860..."
598