mslxdff 0.1.55 → 0.1.57

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 (40) hide show
  1. package/README.md +1 -160
  2. package/bin/mslxdff.js +741 -29
  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 +56 -2
  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/sync-opencode.js +152 -0
  29. package/src/time.js +73 -0
  30. package/src/upstream.js +126 -8
  31. package/docs/adr/0001-reasoning-content-injection.md +0 -14
  32. package/docs/adr/0002-models-free-filter.md +0 -12
  33. package/docs/adr/0003-zero-state-no-auth.md +0 -10
  34. package/docs/adr/0004-bearer-token.md +0 -18
  35. package/docs/adr/0005-peer-mesh.md +0 -53
  36. package/docs/adr/0006-broadband-member.md +0 -103
  37. package/docs/agents/domain.md +0 -51
  38. package/docs/agents/issue-tracker.md +0 -30
  39. package/docs/agents/triage-labels.md +0 -15
  40. 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
+ }
@@ -1,9 +1,11 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { injectReasoningContent, normalizeModel } from "../../reasoning.js";
3
3
  import { isAutoModel } from "../../auto.js";
4
+ import { toInternalId as aliasToInternal } from "../../sync-opencode.js";
4
5
  import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "../helpers.js";
5
6
  import { hedgeDelayMs, shouldHedge } from "../hedge.js";
6
7
  import { runHook } from "../../plugins.js";
8
+ import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
7
9
  import { handleHedge } from "./hedge-handler.js";
8
10
  import { handleLocalRelay } from "./local-handler.js";
9
11
  import { handlePeerRelay } from "./peer-handler.js";
@@ -33,11 +35,47 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
33
35
  const stages = [];
34
36
  const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
35
37
  const hops = parseHops(req.headers["x-mslxdff-hops"]);
38
+ // ADR-0008:瞬时共享 key(组员侧接收)。本请求内有效,用完即弃。
39
+ const shareKeys = parseShareKeysHeader(req.headers[SHARE_KEYS_HEADER] || "");
40
+ const workbuddyUid = (req.headers["x-mslxdff-workbuddy-uid"] || req.headers["x-workbuddy-uid"] || "").toString().trim();
36
41
  const lockModel = req.headers["x-mslxdff-model-lock"] || "";
37
42
  const rawModel = body.model || "";
38
- const requested = normalizeModel(lockModel || rawModel || "");
43
+ const normalizedRequested = normalizeModel(lockModel || rawModel || "");
44
+ // alias 还原:mslxdff-deepseek -> deepseek(原名仍兼容,双向支持 mslxdff/mslxdff-deepseek 与裸 mslxdff-deepseek/裸 deepseek)
45
+ let requested = normalizedRequested;
46
+ let aliasInfo = null;
47
+ if (requested.startsWith("mslxdff-")) {
48
+ const internal = aliasToInternal(requested);
49
+ if (internal) {
50
+ aliasInfo = `${requested} -> ${internal}`;
51
+ requested = internal;
52
+ }
53
+ } else if (requested.includes("/")) {
54
+ const slashIdx = requested.indexOf("/");
55
+ const rawPart = requested.slice(slashIdx + 1);
56
+ const providerPart = requested.slice(0, slashIdx);
57
+ if (rawPart.startsWith("mslxdff-")) {
58
+ const internal = aliasToInternal(rawPart);
59
+ if (internal) {
60
+ aliasInfo = `${requested} -> ${providerPart}/${internal} (alias stripped)`;
61
+ requested = `${providerPart}/${internal}`;
62
+ if (providerPart === "mslxdff") {
63
+ requested = internal;
64
+ aliasInfo = `${rawModel} -> ${internal} (mslxdff alias stripped)`;
65
+ }
66
+ }
67
+ } else if (providerPart === "mslxdff") {
68
+ // mslxdff/deepseek 原名直用 -> deepseek(原名兼容,provider 前缀剥离)
69
+ aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped, 原名兼容)`;
70
+ requested = rawPart;
71
+ }
72
+ }
39
73
  const useAuto = isAutoModel(requested);
40
74
  mark("parsed");
75
+ if (aliasInfo) {
76
+ // 供日志与 header 透传
77
+ try { res.setHeader("x-mslxdff-alias", aliasInfo); } catch {}
78
+ }
41
79
 
42
80
  let order;
43
81
  if (lockModel) {
@@ -66,6 +104,8 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
66
104
  runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
67
105
  };
68
106
  evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
107
+ if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel, requested });
108
+ if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
69
109
  evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
70
110
 
71
111
  if (plugins?.length && !lockModel) {
@@ -106,7 +146,10 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
106
146
  const tUp = performance.now();
107
147
  evt("upstream-try", { reqId, model, attempt: idx + 1 });
108
148
  try {
109
- upRes = await upstream.chat(forwarded);
149
+ const chatOpts = {};
150
+ if (Object.keys(shareKeys).length) chatOpts.shareKeys = shareKeys;
151
+ if (workbuddyUid) chatOpts.workbuddyUid = workbuddyUid;
152
+ upRes = await upstream.chat(forwarded, Object.keys(chatOpts).length ? chatOpts : undefined);
110
153
  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
154
  } catch (err) {
112
155
  if (auto) await auto.recordError(model, { message: errMsg(err) });
@@ -125,6 +168,17 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
125
168
  }
126
169
  mark(`up-${model}`);
127
170
  if (upRes && upRes.status >= 400) {
171
+ // 白名单 403 直通,不计冷却、不 fallback
172
+ const isAllowlistBlock = upRes.status === 403 && (upRes.headers?.get?.("x-mslxdff-allowlist") === "1");
173
+ if (isAllowlistBlock) {
174
+ let bodyText = null;
175
+ try { bodyText = await upRes.clone().text(); } catch {}
176
+ let errBody = { error: `model not allowed for provider` };
177
+ try { errBody = bodyText ? JSON.parse(bodyText) : errBody; } catch { errBody = { error: bodyText || "model not allowed" }; }
178
+ logError(model, 403, errBody.error || "model not allowed");
179
+ evt("upstream-error", { reqId, model, status: 403, message: errBody.error, timing: upRes._t ?? null });
180
+ return json(res, 403, errBody);
181
+ }
128
182
  if (auto) await auto.recordError(model, { status: upRes.status });
129
183
  lastErr = { model, upstream: upRes, status: upRes.status, message: null };
130
184
  logError(model, upRes.status, `upstream ${upRes.status}`);