mslxdff 0.1.55 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -160
  2. package/bin/mslxdff.js +666 -26
  3. package/package.json +3 -2
  4. package/src/chat/config.js +10 -0
  5. package/src/chat/index.js +6 -0
  6. package/src/chat/prompt.js +63 -0
  7. package/src/chat/repl.js +245 -0
  8. package/src/chat/spinner.js +38 -0
  9. package/src/chat/stats.js +170 -0
  10. package/src/chat/store.js +48 -0
  11. package/src/chat/tools.js +304 -0
  12. package/src/chat/upstream.js +90 -0
  13. package/src/models.js +90 -2
  14. package/src/providers/dispatcher.js +100 -0
  15. package/src/providers/generic.js +221 -0
  16. package/src/providers/index.js +6 -0
  17. package/src/providers/keyring.js +38 -0
  18. package/src/providers/model-id.js +36 -0
  19. package/src/providers/opencode.js +20 -0
  20. package/src/providers/openrouter.js +217 -0
  21. package/src/providers/share-keys.js +53 -0
  22. package/src/providers/workbuddy-balance.js +76 -0
  23. package/src/providers/workbuddy.js +563 -0
  24. package/src/routes/chat/index.js +20 -1
  25. package/src/routes/peers.js +12 -7
  26. package/src/routes/stream.js +9 -0
  27. package/src/state.js +299 -4
  28. package/src/time.js +73 -0
  29. package/src/upstream.js +126 -8
  30. package/docs/adr/0001-reasoning-content-injection.md +0 -14
  31. package/docs/adr/0002-models-free-filter.md +0 -12
  32. package/docs/adr/0003-zero-state-no-auth.md +0 -10
  33. package/docs/adr/0004-bearer-token.md +0 -18
  34. package/docs/adr/0005-peer-mesh.md +0 -53
  35. package/docs/adr/0006-broadband-member.md +0 -103
  36. package/docs/agents/domain.md +0 -51
  37. package/docs/agents/issue-tracker.md +0 -30
  38. package/docs/agents/triage-labels.md +0 -15
  39. package/docs/plugins.md +0 -185
@@ -0,0 +1,563 @@
1
+ import { joinModelId } from "./model-id.js";
2
+ import { createKeyRing } from "./keyring.js";
3
+ import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
4
+ import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
7
+
8
+ let UndiciAgent = null;
9
+ let UndiciFetch = null;
10
+ try {
11
+ const mod = await import("undici");
12
+ UndiciAgent = mod.Agent;
13
+ UndiciFetch = mod.fetch;
14
+ } catch {}
15
+
16
+ function envInt(name, fallback) {
17
+ const v = Number(process.env[name]);
18
+ return Number.isInteger(v) && v > 0 ? v : fallback;
19
+ }
20
+
21
+ function resolveBaseUrl(baseUrl) {
22
+ if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
23
+ const env = loadProviderBaseUrl("workbuddy");
24
+ if (env) return env;
25
+ return WORKBUDDY_DEFAULT_BASE_URL;
26
+ }
27
+
28
+ function buildAuthHeaders(key, auth) {
29
+ const h = {
30
+ "Content-Type": "application/json",
31
+ Accept: "text/event-stream",
32
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
33
+ Origin: "https://www.codebuddy.cn",
34
+ Referer: "https://www.codebuddy.cn/",
35
+ "X-Product": "SaaS",
36
+ };
37
+ if (key) h["Authorization"] = `Bearer ${key}`;
38
+ if (auth?.uid) h["X-User-Id"] = auth.uid;
39
+ if (auth?.domain) h["X-Domain"] = auth.domain;
40
+ else h["X-Domain"] = "www.codebuddy.cn";
41
+ if (auth?.enterpriseId) {
42
+ h["X-Enterprise-Id"] = auth.enterpriseId;
43
+ h["X-Tenant-Id"] = auth.enterpriseId;
44
+ }
45
+ return h;
46
+ }
47
+
48
+ function isInsufficientStatus(status, bodyText, cached) {
49
+ if (cached && Number(cached.total) === 0) return true;
50
+ if (status === 402) return true;
51
+ if (status === 403 || status === 429) {
52
+ const t = String(bodyText || "").toLowerCase();
53
+ if (t.includes("insufficient") || t.includes("quota") || t.includes("balance") || t.includes("credit") || t.includes("exhaust") || t.includes("402") || t.includes("10002") || t.includes("10003")) return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function withUidHeader(res, uid) {
59
+ try {
60
+ // try mutable set first
61
+ res.headers.set("x-mslxdff-workbuddy-uid", uid);
62
+ return res;
63
+ } catch {
64
+ try {
65
+ const h = new Headers(res.headers);
66
+ h.set("x-mslxdff-workbuddy-uid", uid);
67
+ // clone with new headers, preserve body stream without consuming
68
+ const clone = new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
69
+ clone._t = res._t;
70
+ return clone;
71
+ } catch {
72
+ return res;
73
+ }
74
+ }
75
+ }
76
+
77
+ function appendRotationLog({ uid, model, totalMs, balanceHit, error }) {
78
+ try {
79
+ const dirs = new Set();
80
+ try {
81
+ const sf = process.env.MSLXDFF_STATE_FILE;
82
+ if (sf) {
83
+ const cut = Math.max(sf.lastIndexOf("/"), sf.lastIndexOf("\\"));
84
+ if (cut > 0) dirs.add(sf.slice(0, cut));
85
+ }
86
+ } catch {}
87
+ const envDir = process.env.MSLXDFF_DAEMON_DIR || process.env.MSLXDFF_LOG_DIR;
88
+ if (envDir) dirs.add(envDir);
89
+ dirs.add(process.cwd());
90
+ dirs.add(join(process.cwd(), "logs"));
91
+ const line = `${new Date().toISOString()} uid=${uid} model=${model||"-"} totalMs=${totalMs} balanceHit=${balanceHit?1:0}${error?` error=${String(error).slice(0,120)}`:""}\n`;
92
+ for (const dir of dirs) {
93
+ try {
94
+ mkdirSync(dir, { recursive: true });
95
+ const file = join(dir, "workbuddy-rotation.log");
96
+ appendFileSync(file, line);
97
+ try {
98
+ const st = statSync(file);
99
+ if (st.size > 1024*1024) {
100
+ const content = readFileSync(file, "utf8");
101
+ writeFileSync(file, content.slice(-512*1024));
102
+ }
103
+ } catch {}
104
+ } catch {}
105
+ }
106
+ } catch {}
107
+ }
108
+
109
+ export function createWorkbuddyProvider({
110
+ baseUrl,
111
+ apiKeys,
112
+ apiKey,
113
+ auths,
114
+ connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
115
+ cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
116
+ retry = {
117
+ network: { attempts: 2, delayMs: 300 },
118
+ 429: { attempts: 1, delayMs: 100 },
119
+ 502: { attempts: 1, delayMs: 100 },
120
+ 503: { attempts: 1, delayMs: 100 },
121
+ 504: { attempts: 1, delayMs: 100 },
122
+ },
123
+ fetchImpl,
124
+ file,
125
+ } = {}) {
126
+ const id = "workbuddy";
127
+ const resolvedBase = resolveBaseUrl(baseUrl);
128
+ if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
129
+
130
+ // keys 优先显式传入,其次 state
131
+ const keysFromState = loadProviderKeys(id, file ? { file } : {});
132
+ const authsFromState = loadProviderAuths(id, file ? { file } : {});
133
+ const keys = (() => {
134
+ const list = [
135
+ ...(Array.isArray(apiKeys) ? apiKeys : [apiKeys].filter(Boolean)),
136
+ apiKey,
137
+ ...(apiKeys === undefined && apiKey === undefined ? keysFromState : []),
138
+ ].filter((k) => typeof k === "string" && k.trim().length);
139
+ return [...new Set(list.map((k) => k.trim()))];
140
+ })();
141
+ let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
142
+
143
+ // fallback scan auths/workbuddy-*.json if still empty
144
+ if (!authList.length && !keys.length) {
145
+ try {
146
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
147
+ if (existsSync(authDir)) {
148
+ const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
149
+ for (const f of files) {
150
+ try {
151
+ const j = JSON.parse(readFileSync(join(authDir, f), "utf8"));
152
+ if (j?.auth?.accessToken && j?.account?.uid) {
153
+ if (!keys.includes(j.auth.accessToken)) keys.push(j.auth.accessToken);
154
+ authList.push({
155
+ uid: j.account.uid,
156
+ domain: j.auth.domain || "www.codebuddy.cn",
157
+ enterpriseId: j.account.enterpriseId || "",
158
+ refreshToken: j.auth.refreshToken || "",
159
+ });
160
+ }
161
+ } catch {}
162
+ }
163
+ }
164
+ } catch {}
165
+ }
166
+
167
+ const ring = createKeyRing(keys, { cooldownMs });
168
+
169
+ let dispatcher = null;
170
+ let agent = null;
171
+ if (UndiciAgent) {
172
+ try {
173
+ agent = new UndiciAgent({
174
+ keepAliveTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_TIMEOUT", 30_000),
175
+ keepAliveMaxTimeout: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_MAX_TIMEOUT", 60_000),
176
+ connections: envInt("MSLXDFF_WORKBUDDY_KEEPALIVE_CONNECTIONS", 20),
177
+ pipelining: 1,
178
+ });
179
+ dispatcher = agent;
180
+ } catch {}
181
+ }
182
+
183
+ function authForKey(key) {
184
+ const idx = keys.indexOf(key);
185
+ if (idx >= 0 && authList[idx]) return authList[idx];
186
+ // try match by uid if key's JWT contains uid? fallback to first auth
187
+ if (authList.length) return authList[0];
188
+ return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
189
+ }
190
+
191
+ async function refreshTokenFor(key, auth) {
192
+ const rt = auth?.refreshToken;
193
+ const uid = auth?.uid;
194
+ if (!rt || !uid) return null;
195
+ const url = `${resolvedBase}/v2/plugin/auth/token/refresh`;
196
+ const headers = {
197
+ "Content-Type": "application/json",
198
+ Authorization: `Bearer ${key}`,
199
+ "X-Refresh-Token": rt,
200
+ "X-User-Id": uid,
201
+ "X-Domain": auth.domain || "www.codebuddy.cn",
202
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
203
+ Origin: "https://www.codebuddy.cn",
204
+ Referer: "https://www.codebuddy.cn/",
205
+ };
206
+ try {
207
+ const opts = { method: "POST", headers, body: "{}" };
208
+ if (dispatcher) opts.dispatcher = dispatcher;
209
+ const res = await fetchImpl(url, opts);
210
+ const text = await res.text();
211
+ const j = JSON.parse(text);
212
+ if (j.code === 0 && j.data?.accessToken) {
213
+ const newAt = j.data.accessToken;
214
+ const newRt = j.data.refreshToken || rt;
215
+ // update in-memory
216
+ const idx = keys.indexOf(key);
217
+ if (idx >= 0) {
218
+ keys[idx] = newAt;
219
+ // also update ring? createKeyRing holds reference? need to reset
220
+ // simplest: save to state and update authList
221
+ authList[idx] = { ...auth, refreshToken: newRt };
222
+ try {
223
+ saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
224
+ } catch {}
225
+ try {
226
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
227
+ mkdirSync(authDir, { recursive: true });
228
+ const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
229
+ const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
230
+ const fp = join(authDir, `workbuddy-${uid}.json`);
231
+ const tmp = fp + ".tmp";
232
+ writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
233
+ try { if (existsSync(fp)) { const { unlinkSync, renameSync } = await import("node:fs"); unlinkSync(fp); renameSync(tmp, fp); } else { const { renameSync } = await import("node:fs"); renameSync(tmp, fp); } } catch { writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 }); }
234
+ } catch {}
235
+ }
236
+ return newAt;
237
+ }
238
+ } catch {}
239
+ return null;
240
+ }
241
+
242
+ async function attemptOnce(url, body, key) {
243
+ const controller = new AbortController();
244
+ const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
245
+ try {
246
+ const auth = authForKey(key);
247
+ const headers = buildAuthHeaders(key, auth);
248
+ // ensure stream true
249
+ const finalBody = { ...body, stream: true };
250
+ const opts = { method: "POST", headers, body: JSON.stringify(finalBody), signal: controller.signal };
251
+ if (dispatcher) opts.dispatcher = dispatcher;
252
+ const res = await fetchImpl(url, opts);
253
+ return res;
254
+ } catch (err) {
255
+ return err;
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ }
260
+
261
+ async function runChat(body, activeRing, opts = {}) {
262
+ const url = `${resolvedBase}/v2/chat/completions`;
263
+ const t0 = performance.now();
264
+ const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
265
+ const modelForLog = body?.model || "";
266
+ // manual pin: force single uid
267
+ if (preferredUid) {
268
+ const idx = authList.findIndex(a => a.uid === preferredUid || a.uid.startsWith(preferredUid));
269
+ if (idx < 0) {
270
+ const errBody = JSON.stringify({ error: `workbuddy uid not found: ${preferredUid}` });
271
+ const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-not-found", "x-mslxdff-workbuddy-uid": preferredUid } });
272
+ res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
273
+ return res;
274
+ }
275
+ const key = keys[idx];
276
+ const auth = authList[idx];
277
+ const cached = getCachedBalance(auth.uid);
278
+ if (cached && Number(cached.total) === 0) {
279
+ const errBody = JSON.stringify({ error: `workbuddy uid in cooldown (balance 0): ${auth.uid}` });
280
+ const res = new Response(errBody, { status: 403, headers: { "Content-Type": "application/json", "x-mslxdff-workbuddy-reason": "uid-cooling", "x-mslxdff-workbuddy-uid": auth.uid } });
281
+ res._t = { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
282
+ return res;
283
+ }
284
+ // check ring cooldown by peeking if key is cooling (ring doesn't expose, so try next and see)
285
+ // we enforce by attempting; if ring would skip, we still allow manual but mark cooling
286
+ // do single attempt with this key
287
+ const attempts = [];
288
+ let waitMs = 0;
289
+ for (let attempt = 0; ; attempt++) {
290
+ const t = performance.now();
291
+ let result = await attemptOnce(url, body, key);
292
+ const type = result instanceof Error ? "network" : `http${result?.status}`;
293
+ attempts.push({ attempt, type, ms: Math.round(performance.now() - t) });
294
+ if (result instanceof Error) {
295
+ const entry = retry?.network;
296
+ if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
297
+ activeRing.onError(key);
298
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
299
+ throw result;
300
+ }
301
+ if ((result.status === 401 || result.status === 403) && attempt === 0) {
302
+ const newKey = await refreshTokenFor(key, auth);
303
+ if (newKey) {
304
+ const auth2 = authForKey(newKey);
305
+ const headers2 = buildAuthHeaders(newKey, auth2);
306
+ const finalBody = { ...body, stream: true };
307
+ const controller2 = new AbortController();
308
+ const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
309
+ try {
310
+ const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
311
+ if (dispatcher) opts2.dispatcher = dispatcher;
312
+ let res2 = await fetchImpl(url, opts2);
313
+ res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
314
+ res2 = withUidHeader(res2, auth2.uid || auth.uid);
315
+ if (res2.status === 401 || res2.status === 403 || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
316
+ appendRotationLog({ uid: auth2.uid || auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
317
+ return res2;
318
+ } catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; throw e2; } finally { clearTimeout(timer2); }
319
+ }
320
+ }
321
+ const entry = retry?.[result.status];
322
+ if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
323
+ if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
324
+ result = withUidHeader(result, auth.uid);
325
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
326
+ appendRotationLog({ uid: auth.uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
327
+ return result;
328
+ }
329
+ }
330
+
331
+ // auto rotation: try each key at most once, with ring.next() order
332
+ const attempts = [];
333
+ let waitMs = 0;
334
+ let lastErr = null;
335
+ const tried = new Set();
336
+ const maxTries = Math.max(1, keys.length);
337
+ for (let triedCount = 0; triedCount < maxTries; triedCount++) {
338
+ const key = activeRing.next();
339
+ if (!key) {
340
+ if (triedCount === 0) {
341
+ const err = new Error(`${id}: all API keys are in cooldown (last error < ${cooldownMs}ms ago) — provider temporarily unavailable`);
342
+ err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), cooldownMs };
343
+ throw err;
344
+ }
345
+ break;
346
+ }
347
+ const auth = authForKey(key);
348
+ const uid = auth?.uid || "";
349
+ if (tried.has(uid || key)) continue;
350
+ tried.add(uid || key);
351
+ const cached = uid ? getCachedBalance(uid) : null;
352
+ if (cached && Number(cached.total) === 0) {
353
+ // treat as insufficient without request, cooldown and try next
354
+ activeRing.onError(key);
355
+ setCachedBalance(uid, { ...cached, total: 0 });
356
+ appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true });
357
+ continue;
358
+ }
359
+ if (!key && activeRing.size === 0) {
360
+ const err = new Error(`${id}: missing MSLXDFF_WORKBUDDY_KEY (chat requires a real key) — run node workbuddy-token-auto.js`);
361
+ err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
362
+ throw err;
363
+ }
364
+ // single attempt with retry for network/429
365
+ let result = null;
366
+ let attempt = 0;
367
+ for (; ; attempt++) {
368
+ const t = performance.now();
369
+ result = await attemptOnce(url, body, key);
370
+ const type = result instanceof Error ? "network" : `http${result?.status}`;
371
+ attempts.push({ attempt: triedCount*10+attempt, type, ms: Math.round(performance.now() - t), uid });
372
+ if (result instanceof Error) {
373
+ const entry = retry?.network;
374
+ if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
375
+ activeRing.onError(key);
376
+ lastErr = result;
377
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
378
+ break;
379
+ }
380
+ if ((result.status === 401 || result.status === 403) && attempt === 0) {
381
+ const newKey = await refreshTokenFor(key, auth);
382
+ if (newKey) {
383
+ const auth2 = authForKey(newKey);
384
+ const headers2 = buildAuthHeaders(newKey, auth2);
385
+ const finalBody = { ...body, stream: true };
386
+ const controller2 = new AbortController();
387
+ const timer2 = setTimeout(() => controller2.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
388
+ try {
389
+ const opts2 = { method: "POST", headers: headers2, body: JSON.stringify(finalBody), signal: controller2.signal };
390
+ if (dispatcher) opts2.dispatcher = dispatcher;
391
+ let res2 = await fetchImpl(url, opts2);
392
+ res2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0), refreshed: true };
393
+ if (res2.status === 401 || res2.status === 403 || res2.status === 429 || res2.status >= 500) activeRing.onError(newKey);
394
+ res2 = withUidHeader(res2, auth2.uid || uid);
395
+ appendRotationLog({ uid: auth2.uid || uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
396
+ return res2;
397
+ } catch (e2) { e2._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) }; lastErr = e2; break; } finally { clearTimeout(timer2); }
398
+ }
399
+ }
400
+ const entry = retry?.[result.status];
401
+ if (entry && attempt < entry.attempts) { await sleep(entry.delayMs); waitMs += entry.delayMs; continue; }
402
+ break;
403
+ }
404
+ if (result instanceof Error) {
405
+ // network error already handled, try next key if any
406
+ continue;
407
+ }
408
+ // check insufficient -> rotate
409
+ let bodyText = "";
410
+ let isInsuff = false;
411
+ if (result.status === 402 || result.status === 403 || result.status === 429) {
412
+ try { bodyText = await result.clone().text(); } catch { try { bodyText = await result.text(); } catch { bodyText = ""; } }
413
+ isInsuff = isInsufficientStatus(result.status, bodyText, cached);
414
+ }
415
+ if (isInsuff) {
416
+ activeRing.onError(key);
417
+ if (uid) setCachedBalance(uid, { total: 0, dailyPacks: 0, activeCount: 0, nextExpire: null, fetchedAt: Date.now() });
418
+ appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: true, error: bodyText.slice(0,120) });
419
+ // need to recreate response for next loop? we already cloned, so result is still usable but we discard it
420
+ lastErr = new Error(`workbuddy insufficient for ${uid}: ${bodyText.slice(0,120)}`);
421
+ lastErr._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
422
+ continue;
423
+ }
424
+ // success or non-insufficient error -> return
425
+ if (result.status === 401 || result.status === 403 || result.status === 429 || result.status >= 500) activeRing.onError(key);
426
+ result = withUidHeader(result, uid);
427
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
428
+ appendRotationLog({ uid, model: modelForLog, totalMs: Math.round(performance.now() - t0), balanceHit: false });
429
+ return result;
430
+ }
431
+ // all tried, none succeeded
432
+ if (lastErr) throw lastErr;
433
+ const err = new Error(`${id}: all workbuddy accounts exhausted or unavailable`);
434
+ err._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
435
+ throw err;
436
+ }
437
+
438
+ async function chat(body, opts = {}) {
439
+ // support header/model pin via opts or body._workbuddyUid
440
+ const uid = opts?.workbuddyUid || body?._workbuddyUid;
441
+ const cleanBody = uid ? (({ _workbuddyUid, ...rest }) => rest)(body) : body;
442
+ return runChat(cleanBody, ring, uid ? { workbuddyUid: uid } : {});
443
+ }
444
+
445
+ async function chatWithKeys(body, keysOverride) {
446
+ // workbuddy 默认不外借,需显式 MSLXDFF_WORKBUDDY_SHARE_KEYS=1 或 state providerShareKeys.workbuddy=true
447
+ const shareOn = (() => {
448
+ try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
449
+ })();
450
+ if (!shareOn) {
451
+ // fallback to normal chat (ignore share)
452
+ return runChat(body, ring);
453
+ }
454
+ const tmp = createKeyRing(keysOverride, { cooldownMs });
455
+ const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
456
+ const savedKeys = [...keys];
457
+ const savedAuthList = [...authList];
458
+ try {
459
+ keys.length = 0; keys.push(...keysOverride);
460
+ authList = keysOverride.map(() => tmpAuth);
461
+ return await runChat(body, tmp);
462
+ } finally {
463
+ keys.length = 0; keys.push(...savedKeys);
464
+ authList = savedAuthList;
465
+ }
466
+ }
467
+
468
+ // listModels / preheat shared
469
+ const CACHE_TTL_MS = 10 * 60 * 1000;
470
+ let cache = null;
471
+ let fetchedAt = 0;
472
+
473
+ function creditsValue(c) {
474
+ if (!c || !String(c).trim()) return 0;
475
+ const m = String(c).match(/x([\d.]+)/);
476
+ return m ? parseFloat(m[1]) : 999;
477
+ }
478
+
479
+ async function listModels() {
480
+ const now = Date.now();
481
+ if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
482
+ const url = `${resolvedBase}/console/enterprises/personal/models`;
483
+ const controller = new AbortController();
484
+ const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
485
+ try {
486
+ const key = ring.next() || keys[0] || "";
487
+ const auth = authForKey(key);
488
+ const headers = {
489
+ Accept: "application/json",
490
+ "X-User-Id": auth?.uid || "",
491
+ "X-Domain": auth?.domain || "www.codebuddy.cn",
492
+ "X-Product": "SaaS",
493
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
494
+ Origin: "https://www.codebuddy.cn",
495
+ Referer: "https://www.codebuddy.cn/",
496
+ };
497
+ if (key) headers["Authorization"] = `Bearer ${key}`;
498
+ const opts = { headers, signal: controller.signal };
499
+ if (dispatcher) opts.dispatcher = dispatcher;
500
+ const res = await fetchImpl(url, opts);
501
+ if (!res.ok) return [];
502
+ const json = await res.json().catch(() => ({}));
503
+ const models = json?.data?.models;
504
+ if (!Array.isArray(models)) return [];
505
+ const sorted = [...models].sort((a, b) => creditsValue(a.credits) - creditsValue(b.credits));
506
+ cache = sorted.filter((m) => m && typeof m.id === "string").map((m) => ({ ...m, id: joinModelId(id, m.id) }));
507
+ fetchedAt = now;
508
+ return cache;
509
+ } catch {
510
+ return [];
511
+ } finally {
512
+ clearTimeout(timer);
513
+ }
514
+ }
515
+
516
+ async function preheat() {
517
+ const url = `${resolvedBase}/console/enterprises/personal/models`;
518
+ const t0 = performance.now();
519
+ try {
520
+ const key = ring.next() || keys[0] || "";
521
+ const auth = authForKey(key);
522
+ const headers = {
523
+ Accept: "application/json",
524
+ "X-User-Id": auth?.uid || "",
525
+ "X-Domain": auth?.domain || "www.codebuddy.cn",
526
+ "X-Product": "SaaS",
527
+ "User-Agent": "CLI/2.115.0 WorkBuddy/2.115.0",
528
+ Origin: "https://www.codebuddy.cn",
529
+ Referer: "https://www.codebuddy.cn/",
530
+ };
531
+ if (key) headers["Authorization"] = `Bearer ${key}`;
532
+ const opts = { headers };
533
+ if (dispatcher) opts.dispatcher = dispatcher;
534
+ const res = await fetchImpl(url, opts);
535
+ try { if (res.body) await res.text().catch(() => {}); } catch {}
536
+ return { ok: res.ok, status: res.status, ms: Math.round(performance.now() - t0) };
537
+ } catch (err) {
538
+ return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
539
+ }
540
+ }
541
+
542
+ async function close() {
543
+ if (agent && typeof agent.close === "function") {
544
+ try { await agent.close(); } catch {}
545
+ }
546
+ }
547
+
548
+ return {
549
+ id,
550
+ chat,
551
+ chatWithKeys,
552
+ listModels,
553
+ preheat,
554
+ close,
555
+ agent,
556
+ keyRing: ring,
557
+ baseUrl: resolvedBase,
558
+ };
559
+ }
560
+
561
+ function sleep(ms) {
562
+ return new Promise((r) => setTimeout(r, ms));
563
+ }
@@ -4,6 +4,7 @@ import { isAutoModel } from "../../auto.js";
4
4
  import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "../helpers.js";
5
5
  import { hedgeDelayMs, shouldHedge } from "../hedge.js";
6
6
  import { runHook } from "../../plugins.js";
7
+ import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
7
8
  import { handleHedge } from "./hedge-handler.js";
8
9
  import { handleLocalRelay } from "./local-handler.js";
9
10
  import { handlePeerRelay } from "./peer-handler.js";
@@ -33,6 +34,9 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
33
34
  const stages = [];
34
35
  const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
35
36
  const hops = parseHops(req.headers["x-mslxdff-hops"]);
37
+ // ADR-0008:瞬时共享 key(组员侧接收)。本请求内有效,用完即弃。
38
+ const shareKeys = parseShareKeysHeader(req.headers[SHARE_KEYS_HEADER] || "");
39
+ const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
36
40
  const lockModel = req.headers["x-mslxdff-model-lock"] || "";
37
41
  const rawModel = body.model || "";
38
42
  const requested = normalizeModel(lockModel || rawModel || "");
@@ -66,6 +70,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
66
70
  runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
67
71
  };
68
72
  evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
73
+ if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
69
74
  evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
70
75
 
71
76
  if (plugins?.length && !lockModel) {
@@ -106,7 +111,10 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
106
111
  const tUp = performance.now();
107
112
  evt("upstream-try", { reqId, model, attempt: idx + 1 });
108
113
  try {
109
- upRes = await upstream.chat(forwarded);
114
+ const chatOpts = {};
115
+ if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
116
+ if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
117
+ upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
110
118
  evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
111
119
  } catch (err) {
112
120
  if (auto) await auto.recordError(model, { message: errMsg(err) });
@@ -125,6 +133,17 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
125
133
  }
126
134
  mark(`up-${model}`);
127
135
  if (upRes && upRes.status >= 400) {
136
+ // 白名单 403 直通,不计冷却、不 fallback
137
+ const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
138
+ if (isAllowlistBlock) {
139
+ let bodyText = null;
140
+ try { bodyText = await upRes.clone().text(); } catch {}
141
+ let errBody = { error: `model not allowed for provider` };
142
+ try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
143
+ logError(model, 403, errBody.error || "model not allowed");
144
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null });
145
+ return json(res, 403, errBody);
146
+ }
128
147
  if (auto) await auto.recordError(model, { status: upRes.status });
129
148
  lastErr = { model, upstream: upRes, status: upRes.status, message: null };
130
149
  logError(model, upRes.status, `upstream ${upRes.status}`);
@@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks";
2
2
  import { isAutoModel } from "../auto.js";
3
3
  import { errMsg } from "./helpers.js";
4
4
  import { runHook } from "../plugins.js";
5
+ import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
5
6
 
6
7
  const PEER_TIMEOUT_MS = 30_000;
7
8
  const PEER_STATUS_TIMEOUT_MS = 2_000;
@@ -63,15 +64,19 @@ async function forwardToPeer(peer, body, model, hops) {
63
64
  const controller = new AbortController();
64
65
  const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
65
66
  try {
67
+ const headers = {
68
+ "Content-Type": "application/json",
69
+ "Authorization": `Bearer ${peer.token}`,
70
+ "x-mslxdff-hops": String(hops + 1),
71
+ "x-mslxdff-model-lock": model,
72
+ "Accept": "text/event-stream",
73
+ };
74
+ // ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
75
+ const shareHeader = buildShareKeysHeader(model);
76
+ if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
66
77
  return await fetch(`${peer.url}/v1/chat/completions`, {
67
78
  method: "POST",
68
- headers: {
69
- "Content-Type": "application/json",
70
- "Authorization": `Bearer ${peer.token}`,
71
- "x-mslxdff-hops": String(hops + 1),
72
- "x-mslxdff-model-lock": model,
73
- "Accept": "text/event-stream",
74
- },
79
+ headers,
75
80
  body: JSON.stringify({ ...body, model }),
76
81
  signal: controller.signal,
77
82
  });
@@ -33,6 +33,15 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
33
33
  const contentType = upRes.headers.get("content-type") || "";
34
34
  const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
35
35
  res.statusCode = upRes.status;
36
+ // propagate workbuddy uid / allowlist headers
37
+ try {
38
+ const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
39
+ if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
40
+ const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
41
+ if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
42
+ const allow = upRes.headers.get("x-mslxdff-allowlist");
43
+ if (allow) res.setHeader("x-mslxdff-allowlist", allow);
44
+ } catch {}
36
45
  if (fallback) applyFallbackHeaders(res, fallback);
37
46
 
38
47
  let ttf = null;