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
package/bin/mslxdff.js CHANGED
@@ -8,10 +8,11 @@ import { DEFAULT_PORT, defaultStateFile } from "../src/state.js";
8
8
  import { createRouter } from "../src/routes.js";
9
9
  import { createUpstreamClient } from "../src/upstream.js";
10
10
  import { createModelsService } from "../src/models.js";
11
- import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks } from "../src/state.js";
11
+ import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks, loadProviderKey, loadProviderKeys, loadProviderAuths, loadProviderConfigs as loadProviderConfigsState } from "../src/state.js";
12
12
  import { getPreferredModel } from "../src/auto.js";
13
13
  import { normalizeModel } from "../src/reasoning.js";
14
14
  import { syncToWorkbuddy, workbuddyModelsPath } from "../src/sync-workbuddy.js";
15
+ import { syncToOpencode, opencodeConfigPath, toExternalAlias, toInternalId } from "../src/sync-opencode.js";
15
16
  import { startDaemon, stopDaemon, writePid, pidFile, logFile, readPid, readPidVersion, isPidAlive } from "../src/daemon.js";
16
17
  import { createAutoSelector } from "../src/auto.js";
17
18
  import { createPeersService } from "../src/peers.js";
@@ -19,6 +20,8 @@ import { createEventBus } from "../src/events.js";
19
20
  import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
20
21
  import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
21
22
  import { loadPlugins, runHook, pluginsDir, resolvePluginDirs } from "../src/plugins.js";
23
+ import { createOpenCodeProvider } from "../src/providers/opencode.js";
24
+ import { fmtShanghai, fmtShanghaiYMDHM, fmtShanghaiHMS } from "../src/time.js";
22
25
 
23
26
  const logs = { appendCall, appendError, appendEvent };
24
27
 
@@ -134,6 +137,17 @@ if (args.includes("-plugins") || args.includes("--plugins")) {
134
137
  process.exit(0);
135
138
  }
136
139
 
140
+ if (args.includes("-chat") || args.includes("--chat")) {
141
+ const idx = args.findIndex((x) => x === "-chat" || x === "--chat");
142
+ const rest = args.slice(idx + 1);
143
+ // rest 中若全是空白或以 - 开头的“选项”,仍视作本次对话的输入(单次模式);
144
+ // 无 rest 则进入常驻 REPL。REPL 本身就是前台独立进程,与 daemon 无父子关系,daemon 重启不影响它。
145
+ const singleShot = rest.length ? rest.join(" ").trim() : null;
146
+ const { startChat } = await import("../src/chat/index.js");
147
+ await startChat({ singleShot: singleShot || undefined });
148
+ process.exit(0);
149
+ }
150
+
137
151
  if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
138
152
  await printStatus();
139
153
  process.exit(0);
@@ -174,9 +188,7 @@ if (args.includes("-model") || args.includes("-models")) {
174
188
  const e = statuses[id];
175
189
  const st = typeof e === "number" ? "error" : e?.status || "normal";
176
190
  const at = typeof e === "number" ? e : e?.at;
177
- const when = at
178
- ? ` (${new Date(at).toISOString().slice(5, 19).replace("T", " ")})`
179
- : "";
191
+ const when = at ? ` (${fmtShanghai(at)})` : "";
180
192
  const extra = e?.code ? ` HTTP ${e.code}` : "";
181
193
  console.log(` ${id} ${st}${when}${extra}`);
182
194
  }
@@ -289,7 +301,7 @@ if (args.includes("-model") || args.includes("-models")) {
289
301
  console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
290
302
  process.exit(0);
291
303
  }
292
- const at = cachedAt ? ` (cached ${new Date(cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
304
+ const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
293
305
  const pickedIds = loadModelPicks();
294
306
  const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
295
307
  console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
@@ -302,14 +314,85 @@ if (args.includes("-model") || args.includes("-models")) {
302
314
  process.exit(0);
303
315
  }
304
316
 
305
- // -setto workbuddy [modelId]: set default model and sync to WorkBuddy models.json
317
+ // -setto workbuddy|opencode [modelId]: sync to WorkBuddy / opencode
306
318
  if (args.includes("-setto") || args.includes("--setto")) {
307
319
  const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
308
320
  const target = args[idx + 1];
309
- if (target !== "workbuddy") {
310
- console.error("usage: mslxdff -setto workbuddy [modelId]");
321
+ if (!["workbuddy", "opencode"].includes(target)) {
322
+ console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId]");
311
323
  process.exit(1);
312
324
  }
325
+ if (target === "opencode") {
326
+ const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
327
+ let id;
328
+ let internal;
329
+ if (raw) {
330
+ if (raw === "auto" || !raw) {
331
+ console.error("modelId 不能为 auto 或空");
332
+ process.exit(1);
333
+ }
334
+ const norm = normalizeModel(raw);
335
+ if (!norm) {
336
+ console.error("modelId 不能为空");
337
+ process.exit(1);
338
+ }
339
+ savePreferredModel(norm);
340
+ console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
341
+ internal = toInternalId(norm);
342
+ id = toExternalAlias(internal);
343
+ } else {
344
+ const pref = loadPreferredModel() || getPreferredModel();
345
+ if (!pref) {
346
+ console.error("no preferred model set; use: mslxdff -setto opencode <modelId>");
347
+ process.exit(1);
348
+ }
349
+ const norm = normalizeModel(pref);
350
+ if (!norm) {
351
+ console.error("modelId 不能为空");
352
+ process.exit(1);
353
+ }
354
+ internal = toInternalId(norm);
355
+ id = toExternalAlias(internal);
356
+ }
357
+ try {
358
+ const cacheFile = join(logDir(), "models.json");
359
+ const models = createModelsService({
360
+ baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
361
+ headers: createUpstreamClient({}).headers,
362
+ refreshMs: 0,
363
+ cacheFile,
364
+ });
365
+ const fresh = await Promise.race([
366
+ models.get(),
367
+ new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
368
+ ]);
369
+ if (fresh?.data?.length) {
370
+ const ids = fresh.data.map((m) => m.id);
371
+ if (!ids.includes(internal) && !ids.includes(id)) {
372
+ console.log(`warn: "${internal}" not in current free list (${ids.length} models), still syncing to opencode (alias ${id})`);
373
+ }
374
+ }
375
+ } catch {
376
+ // refresh failed, still proceed
377
+ }
378
+ try {
379
+ const { token } = await loadToken();
380
+ const persisted = getPort();
381
+ const envPort = Number(process.env.MSLXDFF_PORT);
382
+ const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
383
+ const file = opencodeConfigPath();
384
+ const r = await syncToOpencode({ id, token, port, file });
385
+ const aliasLabel = r.alias && r.alias !== r.id ? ` (alias ${r.alias} 对应内部 ${r.internal})` : (r.id !== r.internal ? ` (alias for "${r.internal}", 原名仍兼容)` : ` (原名兼容)`);
386
+ console.log(`synced to opencode: ${r.action} "${r.id}"${aliasLabel} @ ${file}`);
387
+ console.log(` url: http://127.0.0.1:${port}/v1`);
388
+ if (r.id !== r.internal) console.log(` alias: ${r.id} -> ${r.internal} (opencode 选 mslxdff/${r.id} 直达本地 ${r.internal})`);
389
+ else console.log(` alias: ${r.internal} (原名直用,opencode 选 mslxdff/${r.id} 直达本地 ${r.internal})`);
390
+ } catch (err) {
391
+ console.error(`failed to sync to opencode: ${String(err?.message || err)}`);
392
+ process.exit(1);
393
+ }
394
+ process.exit(0);
395
+ }
313
396
  const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
314
397
  let id;
315
398
  if (raw) {
@@ -370,6 +453,556 @@ if (args.includes("-setto") || args.includes("--setto")) {
370
453
  process.exit(0);
371
454
  }
372
455
 
456
+ // -workbuddy checkin/balance/list/remove : WorkBuddy 多号管理
457
+ if (args.includes("-workbuddy") || args.includes("--workbuddy") || args.includes("-wb")) {
458
+ const idx = args.findIndex((x) => x === "-workbuddy" || x === "--workbuddy" || x === "-wb");
459
+ const sub = args[idx + 1];
460
+ if (!sub || sub === "checkin" || sub === "daily-checkin" || sub === "check-in") {
461
+ const { spawn } = await import("node:child_process");
462
+ const script = join(dirname(fileURLToPath(import.meta.url)), "..", "workbuddy-checkin.js");
463
+ const child = spawn(process.execPath, [script, ...args.slice(idx + 2)], { stdio: "inherit" });
464
+ child.on("close", (code) => process.exit(code ?? 0));
465
+ child.on("error", (err) => { console.error(`workbuddy checkin failed: ${err.message}`); process.exit(1); });
466
+ await new Promise(() => {});
467
+ } else if (sub === "balance" || sub === "balances" || sub === "credit" || sub === "credits") {
468
+ const asJson = args.includes("--json") || args.includes("-json");
469
+ const { loadProviderConfigs } = await import("../src/state.js");
470
+ const { fetchBalance } = await import("../src/providers/workbuddy-balance.js");
471
+ const cfg = loadProviderConfigs().workbuddy || {};
472
+ const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
473
+ const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
474
+ // fallback scan auths dir if state empty
475
+ let items = auths.map((a,i)=> ({ uid:a.uid, domain:a.domain, key: keys[i]||"" , auth:a }));
476
+ if (!items.length) {
477
+ try {
478
+ const { readdirSync, readFileSync, existsSync } = await import("node:fs");
479
+ const { join } = await import("node:path");
480
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
481
+ if (existsSync(dir)) {
482
+ for (const f of readdirSync(dir).filter(x=>x.startsWith("workbuddy-")&&x.endsWith(".json"))) {
483
+ try { const j=JSON.parse(readFileSync(join(dir,f),"utf8")); if(j?.account?.uid) items.push({uid:j.account.uid, domain:j.auth.domain||"www.codebuddy.cn", key:j.auth.accessToken, auth:{uid:j.account.uid, domain:j.auth.domain||"www.codebuddy.cn", enterpriseId:j.account.enterpriseId||""}}); } catch {}
484
+ }
485
+ }
486
+ } catch {}
487
+ }
488
+ if (!items.length) { console.log("no workbuddy accounts — run node workbuddy-token-auto.js"); process.exit(0); }
489
+ const results=[];
490
+ for (const it of items) {
491
+ const b = await fetchBalance({ uid: it.uid, key: it.key, auth: it.auth }).catch(()=>null);
492
+ results.push({ uid: it.uid, domain: it.domain, balance: b });
493
+ }
494
+ if (asJson) { console.log(JSON.stringify({ results }, null, 2)); process.exit(0); }
495
+ console.log(`workbuddy balances (${results.length}):`);
496
+ for (const r of results) {
497
+ const b=r.balance;
498
+ if (!b) console.log(` ${r.uid} (balance unavailable) domain=${r.domain}`);
499
+ else console.log(` ${r.uid} total=${b.totalStr||b.total} dailyPacks=${b.dailyPacks} active=${b.activeCount} nextExpire=${b.nextExpire||"-"} domain=${r.domain}`);
500
+ }
501
+ process.exit(0);
502
+ } else if (sub === "list" || sub === "ls" || sub === "status") {
503
+ const { loadProviderConfigs } = await import("../src/state.js");
504
+ const cfg = loadProviderConfigs().workbuddy || {};
505
+ const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
506
+ const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
507
+ if (!auths.length) { console.log("no workbuddy accounts in state — check auths/workbuddy-*.json"); process.exit(0); }
508
+ console.log(`workbuddy accounts (${auths.length}):`);
509
+ auths.forEach((a,i)=> {
510
+ const k=(keys[i]||"").slice(0,4);
511
+ console.log(` [${i+1}] uid=${a.uid} domain=${a.domain||"www.codebuddy.cn"} enterprise=${a.enterpriseId||"-"} key=${k?k+"…":"(none)"} refresh=${a.refreshToken?"yes":"no"}`);
512
+ });
513
+ process.exit(0);
514
+ } else if (sub === "remove" || sub === "rm" || sub === "del" || sub === "delete") {
515
+ const target = args[idx+2];
516
+ if (!target) { console.error("usage: mslxdff -workbuddy remove <uid> [--keep-file]"); process.exit(1); }
517
+ const keep = args.includes("--keep-file");
518
+ const { loadProviderConfigs, saveProviderConfig } = await import("../src/state.js");
519
+ const cfg = loadProviderConfigs().workbuddy || {};
520
+ let auths = Array.isArray(cfg.auths)? [...cfg.auths]:[];
521
+ let keys = Array.isArray(cfg.keys)? [...cfg.keys]:[];
522
+ // support prefix match 6 chars
523
+ let rmIdx = auths.findIndex(a=> a.uid===target || a.uid.startsWith(target));
524
+ if (rmIdx<0) { console.error(`uid not found: ${target}`); process.exit(1); }
525
+ const uid = auths[rmIdx].uid;
526
+ auths.splice(rmIdx,1); keys.splice(rmIdx,1);
527
+ saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl||"https://copilot.tencent.com", keys, auths });
528
+ if (!keep) {
529
+ try {
530
+ const { existsSync, unlinkSync } = await import("node:fs");
531
+ const { join } = await import("node:path");
532
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
533
+ const fp = join(dir, `workbuddy-${uid}.json`);
534
+ if (existsSync(fp)) { unlinkSync(fp); console.log(`removed file ${fp}`); }
535
+ } catch {}
536
+ }
537
+ try { const { getBalanceCache } = await import("../src/providers/workbuddy-balance.js"); getBalanceCache().delete(uid); } catch {}
538
+ console.log(`removed workbuddy ${uid} (now ${auths.length} account(s))`);
539
+ process.exit(0);
540
+ } else {
541
+ console.error("usage: mslxdff -workbuddy checkin | balance [--json] | list | remove <uid>");
542
+ process.exit(1);
543
+ }
544
+ }
545
+
546
+ // -providers list : list all deployed upstream providers (opencode + openrouter + generic)
547
+ if (args.includes("-providers") || args.includes("--providers")) {
548
+ const idx = args.findIndex((x) => x === "-providers" || x === "--providers");
549
+ const sub = args[idx + 1];
550
+ if (!sub || sub === "list" || sub === "status") {
551
+ const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl, loadProviderAllowedModels } = await import("../src/state.js");
552
+ const configs = loadProviderConfigs();
553
+ const opencodeEnabled = true;
554
+ const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
555
+ const orKeys = loadProviderKeys("openrouter");
556
+ const orBase = "https://openrouter.ai/api/v1";
557
+ const orShare = loadProviderShareKeys("openrouter");
558
+ const orAllowed = loadProviderAllowedModels("openrouter");
559
+ // collect generic ids from configs + legacy providerKeys
560
+ const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode" && id !== "openrouter"));
561
+ try {
562
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
563
+ const pk = raw.providerKeys || {};
564
+ for (const id of Object.keys(pk)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
565
+ const cfgRaw = raw.providerConfigs || {};
566
+ for (const id of Object.keys(cfgRaw)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
567
+ } catch {}
568
+ // also include env-only generic ids (scan env for MSLXDFF_*_KEY)
569
+ for (const k of Object.keys(process.env)) {
570
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
571
+ if (m) {
572
+ const id = m[1].toLowerCase().replace(/__/g, "-");
573
+ if (id !== "openrouter" && id !== "opencode") genericIds.add(id);
574
+ }
575
+ }
576
+ // include providers that only have allowlist (no keys yet) — exclude built-ins already listed
577
+ try {
578
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
579
+ const cfgs = raw.providerConfigs || {};
580
+ for (const id of Object.keys(cfgs)) {
581
+ if (id === "opencode" || id === "openrouter") continue;
582
+ const am = cfgs[id]?.allowedModels;
583
+ if (Array.isArray(am) && am.length) genericIds.add(id);
584
+ }
585
+ } catch {}
586
+ const list = [];
587
+ const opAllowed = loadProviderAllowedModels("opencode");
588
+ list.push({ id: "opencode", enabled: opencodeEnabled, baseUrl: opencodeBase, keys: [], share: false, allowed: opAllowed, note: "built-in, no key, cannot share" });
589
+ list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, allowed: orAllowed, note: orKeys.length ? "" : "no keys" });
590
+ for (const gid of [...genericIds].sort()) {
591
+ const cfg = configs[gid];
592
+ const keys = loadProviderKeys(gid);
593
+ const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
594
+ const share = loadProviderShareKeys(gid);
595
+ const allowed = loadProviderAllowedModels(gid);
596
+ const enabled = Boolean(baseUrl && keys.length);
597
+ let note = "";
598
+ if (!baseUrl && !keys.length && !allowed.length) note = "no baseUrl, no keys";
599
+ else if (!baseUrl && !allowed.length) note = "missing baseUrl";
600
+ else if (!keys.length && !allowed.length) note = "no keys";
601
+ else if (!baseUrl) note = "missing baseUrl";
602
+ else if (!keys.length) note = "no keys";
603
+ list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, allowed, note });
604
+ }
605
+ console.log(`providers (${list.length}):`);
606
+ for (const p of list) {
607
+ const state = p.enabled ? "enabled " : "disabled";
608
+ const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
609
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
610
+ const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 3).join(",")}${p.allowed.length > 3 ? "..." : ""})` : "allow=all";
611
+ const note = p.note ? ` (${p.note})` : "";
612
+ console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} ${allowInfo.padEnd(22)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
613
+ }
614
+ console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider <id> allowlist set <model...> to restrict`);
615
+ process.exit(0);
616
+ }
617
+ console.error("usage: mslxdff -providers list");
618
+ process.exit(1);
619
+ }
620
+
621
+ // -provider <id> [key...|add|remove|list|clear]: 配置需鉴权的供应商 API key(如 openrouter)。
622
+ // 行为:无参数且 TTY → 交互式隐藏输入(多行直到空行,逐一追加);非 TTY → 打印用法。key 持久化到 state。
623
+ if (args.includes("-provider") || args.includes("--provider")) {
624
+ const idx = args.findIndex((x) => x === "-provider" || x === "--provider");
625
+ const id = args[idx + 1];
626
+ const sub = args[idx + 2];
627
+ const rest = args.slice(idx + 2);
628
+ if (!id) {
629
+ console.error("usage: mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url|allowlist]");
630
+ console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
631
+ console.error(" mslxdff -provider openrouter add sk-4 append one key");
632
+ console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
633
+ console.error(" mslxdff -provider openrouter list list all keys (masked)");
634
+ console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
635
+ console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
636
+ console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=allow all)");
637
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
638
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
639
+ console.error(" mslxdff -provider openrouter interactive hidden input (append)");
640
+ console.error(" mslxdff -provider openrouter clear remove all keys");
641
+ process.exit(1);
642
+ }
643
+ // alias: mslxdff -provider list (same as -providers list) — list all providers
644
+ if (id === "list" || id === "status") {
645
+ const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl } = await import("../src/state.js");
646
+ const configs = loadProviderConfigs();
647
+ const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
648
+ const orKeys = loadProviderKeys("openrouter");
649
+ const orBase = "https://openrouter.ai/api/v1";
650
+ const orShare = loadProviderShareKeys("openrouter");
651
+ const genericIds = new Set(Object.keys(configs).filter((x) => x !== "opencode" && x !== "openrouter"));
652
+ try {
653
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
654
+ const pk = raw.providerKeys || {};
655
+ for (const k of Object.keys(pk)) if (k !== "opencode" && k !== "openrouter") genericIds.add(k);
656
+ } catch {}
657
+ for (const k of Object.keys(process.env)) {
658
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
659
+ if (m) {
660
+ const gid = m[1].toLowerCase().replace(/__/g, "-");
661
+ if (gid !== "openrouter" && gid !== "opencode") genericIds.add(gid);
662
+ }
663
+ }
664
+ const list = [];
665
+ list.push({ id: "opencode", enabled: true, baseUrl: opencodeBase, keys: [], share: false, note: "built-in, no key, cannot share" });
666
+ list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, note: orKeys.length ? "" : "no keys" });
667
+ for (const gid of [...genericIds].sort()) {
668
+ const cfg = configs[gid];
669
+ const keys = loadProviderKeys(gid);
670
+ const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
671
+ const share = loadProviderShareKeys(gid);
672
+ const enabled = Boolean(baseUrl && keys.length);
673
+ let note = "";
674
+ if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
675
+ else if (!baseUrl) note = "missing baseUrl";
676
+ else if (!keys.length) note = "no keys";
677
+ list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, note });
678
+ }
679
+ console.log(`providers (${list.length}):`);
680
+ for (const p of list) {
681
+ const state = p.enabled ? "enabled " : "disabled";
682
+ const keysInfo = p.keys.length ? `${p.keys.length} key${p.keys.length > 1 ? "s" : ""} ${p.keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}` : "0 keys";
683
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
684
+ const note = p.note ? ` (${p.note})` : "";
685
+ console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
686
+ }
687
+ console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider add <id> <baseUrl> <key> to add generic`);
688
+ process.exit(0);
689
+ }
690
+ // 通用供应商快捷添加:mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]
691
+ if (id === "add") {
692
+ const gid = sub;
693
+ const gBase = rest[1];
694
+ const gKey = rest[2];
695
+ if (!gid || !gBase || !gKey) {
696
+ console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]");
697
+ console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
698
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
699
+ process.exit(1);
700
+ }
701
+ if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
702
+ console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
703
+ process.exit(1);
704
+ }
705
+ const { saveProviderConfig, loadProviderConfig, loadProviderShareKeys, loadProviderAllowedModels } = await import("../src/state.js");
706
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
707
+ const nid = normalizeProviderId(gid);
708
+ if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
709
+ if (!/^https?:\/\/.+/.test(String(gBase).trim())) { console.error(`invalid baseUrl: ${gBase} (must start with http:// or https://)`); process.exit(1); }
710
+ const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [] };
711
+ let keys, auths, baseUrl;
712
+ baseUrl = String(gBase).trim();
713
+ const extraModels = rest.slice(3).filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
714
+ const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
715
+ if (nid === "workbuddy") {
716
+ // workbuddy: keys/auths 一一对应,需解析 uid
717
+ const token = String(gKey).trim();
718
+ let uid = "";
719
+ try { const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString()); uid = payload.uid || payload.sub || payload.userId || payload.user_id || ""; } catch {}
720
+ if (!uid) uid = "manual-" + token.slice(-8);
721
+ const curKeys = Array.isArray(cur.keys) ? [...cur.keys] : [];
722
+ const curAuths = Array.isArray(cur.auths) ? [...cur.auths] : [];
723
+ let idx = curAuths.findIndex(a => a.uid === uid);
724
+ if (idx < 0) idx = curKeys.findIndex(k => k === token);
725
+ let newKeys, newAuths;
726
+ if (idx >= 0) {
727
+ newKeys = [...curKeys]; newKeys[idx] = token;
728
+ newAuths = [...curAuths]; newAuths[idx] = { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
729
+ // if auths shorter than keys, pad
730
+ while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
731
+ } else {
732
+ newKeys = [...new Set([...curKeys, token].filter(Boolean))];
733
+ newAuths = [...curAuths, { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" }];
734
+ // align lengths
735
+ while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
736
+ while (newKeys.length < newAuths.length) newKeys.push(token);
737
+ }
738
+ keys = newKeys; auths = newAuths;
739
+ saveProviderConfig(nid, { baseUrl, keys, auths, allowedModels });
740
+ // 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
741
+ try {
742
+ const { writeFileSync, mkdirSync } = await import("node:fs");
743
+ const { join } = await import("node:path");
744
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
745
+ mkdirSync(dir, { recursive: true });
746
+ const fp = join(dir, `workbuddy-${uid}.json`);
747
+ const doc = { account: { uid, enterpriseId: "", nickname: "" }, auth: { accessToken: token, refreshToken: "", expiresAt: Math.floor(Date.now()/1000)+3600, domain: "www.codebuddy.cn" } };
748
+ writeFileSync(fp + ".tmp", JSON.stringify(doc, null, 2), { mode: 0o600 });
749
+ try { const { renameSync, unlinkSync, existsSync } = await import("node:fs"); if (existsSync(fp)) unlinkSync(fp); renameSync(fp + ".tmp", fp); } catch { writeFileSync(fp, JSON.stringify(doc, null, 2), { mode: 0o600 }); }
750
+ } catch {}
751
+ } else {
752
+ keys = [...new Set([...(cur.keys || []), String(gKey).trim()].filter(Boolean))];
753
+ auths = undefined;
754
+ saveProviderConfig(nid, { baseUrl, keys, allowedModels });
755
+ }
756
+ console.log(`added generic provider: ${nid}`);
757
+ console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
758
+ console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
759
+ if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
760
+ else console.log(` allowedModels: (none — allow all, set via: mslxdff -provider ${nid} allowlist set <model...>)`);
761
+ console.log(` share: ${loadProviderShareKeys(nid) ? "ON" : "off"} (mslxdff -provider ${nid} share on|off)`);
762
+ console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
763
+ process.exit(0);
764
+ }
765
+ if ((id === "opencode" || id === "oc") && sub !== "allowlist" && sub !== "allow" && sub !== "allowed" && sub !== "whitelist" && sub !== "list" && sub !== "status") {
766
+ console.log("opencode is the default (bare) provider — it needs no API key and can never be shared with peers");
767
+ console.log("(its IP-based rate limit is spread by peer forwarding itself)");
768
+ console.log(` allowlist: mslxdff -provider opencode allowlist [list|set|add|remove|clear] — restrict models (empty=allow all)`);
769
+ process.exit(0);
770
+ }
771
+ const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
772
+ if (sub === "clear") {
773
+ const configs = loadProviderConfigs();
774
+ if (configs[id]) {
775
+ saveProviderConfig(id, { baseUrl: "", keys: [] });
776
+ } else {
777
+ saveProviderKeys(id, []);
778
+ }
779
+ console.log(`cleared ${id} API keys (provider disabled on next daemon start)`);
780
+ process.exit(0);
781
+ }
782
+ if (sub === "set-url" || sub === "setUrl" || sub === "url") {
783
+ const url = rest[1];
784
+ if (!url) {
785
+ console.error(`usage: mslxdff -provider ${id} set-url <baseUrl>`);
786
+ process.exit(1);
787
+ }
788
+ if (!/^https?:\/\/.+/.test(String(url).trim())) {
789
+ console.error(`invalid baseUrl: ${url} (must start with http:// or https://)`);
790
+ process.exit(1);
791
+ }
792
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
793
+ saveProviderConfig(id, { baseUrl: String(url).trim(), keys: cur.keys || [] });
794
+ console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
795
+ process.exit(0);
796
+ }
797
+ if (sub === "share") {
798
+ const on = rest[1];
799
+ if (!on) {
800
+ console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
801
+ process.exit(0);
802
+ }
803
+ if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
804
+ console.error("usage: mslxdff -provider openrouter share on|off");
805
+ process.exit(1);
806
+ }
807
+ const state = ["on", "1", "true"].includes(String(on).toLowerCase());
808
+ saveProviderShareKeys(id, state);
809
+ console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
810
+ process.exit(0);
811
+ }
812
+ // allowlist 管理:白名单为空 = 不限;非空 = 仅名单内可用(防昂贵模型)
813
+ if (sub === "allowlist" || sub === "allow" || sub === "allowed" || sub === "whitelist") {
814
+ const { loadProviderAllowedModels, saveProviderAllowedModels, loadProviderConfig } = await import("../src/state.js");
815
+ const action = rest[1];
816
+ const rawTargets = rest.slice(2);
817
+ // 兼容:mslxdff -provider <id> allowlist (无 action)→ list
818
+ if (!action || action === "list" || action === "status") {
819
+ const list = loadProviderAllowedModels(id);
820
+ const cfg = loadProviderConfig(id);
821
+ const baseUrl = cfg?.baseUrl || "";
822
+ console.log(`provider: ${id}${baseUrl ? ` baseUrl: ${baseUrl}` : ""}`);
823
+ if (!list.length) {
824
+ console.log(` allowedModels: (none — allow all)`);
825
+ console.log(` set via: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
826
+ console.log(` add: mslxdff -provider ${id} allowlist add <model>`);
827
+ } else {
828
+ console.log(` allowedModels: ${list.length} model${list.length > 1 ? "s" : ""} (only these can be used)`);
829
+ list.forEach((m, i) => console.log(` [${i + 1}] ${m}`));
830
+ console.log(` manage: mslxdff -provider ${id} allowlist add <model> | remove <model> | set <m1> <m2> ... | clear`);
831
+ }
832
+ console.log(` NOTE: empty allowlist = allow all (hot-reloaded, no restart needed)`);
833
+ process.exit(0);
834
+ }
835
+ if (action === "clear") {
836
+ saveProviderAllowedModels(id, []);
837
+ console.log(`cleared ${id} allowlist (now allow all) — takes effect immediately (hot-reloaded)`);
838
+ process.exit(0);
839
+ }
840
+ if (action === "set") {
841
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
842
+ // 支持逗号分隔的一串:model1,model2
843
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
844
+ if (!flat.length) {
845
+ console.error(`usage: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
846
+ process.exit(1);
847
+ }
848
+ const saved = saveProviderAllowedModels(id, flat);
849
+ console.log(`set ${id} allowlist: ${saved.length} model${saved.length > 1 ? "s" : ""} (${saved.join(", ")}) — takes effect immediately (hot-reloaded)`);
850
+ process.exit(0);
851
+ }
852
+ if (action === "add") {
853
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
854
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
855
+ if (!flat.length) {
856
+ console.error(`usage: mslxdff -provider ${id} allowlist add <model> [model2 ...]`);
857
+ process.exit(1);
858
+ }
859
+ const cur = loadProviderAllowedModels(id);
860
+ const next = [...new Set([...cur, ...flat])];
861
+ saveProviderAllowedModels(id, next);
862
+ console.log(`added ${flat.length} model${flat.length > 1 ? "s" : ""} to ${id} allowlist (now ${next.length}: ${next.join(", ")}) — takes effect immediately (hot-reloaded)`);
863
+ process.exit(0);
864
+ }
865
+ if (action === "remove" || action === "rm" || action === "del") {
866
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
867
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
868
+ if (!flat.length) {
869
+ console.error(`usage: mslxdff -provider ${id} allowlist remove <model> [model2 ...]`);
870
+ process.exit(1);
871
+ }
872
+ const cur = loadProviderAllowedModels(id);
873
+ const set = new Set(flat);
874
+ const next = cur.filter((m) => !set.has(m));
875
+ saveProviderAllowedModels(id, next);
876
+ console.log(`removed ${cur.length - next.length} model${cur.length - next.length !== 1 ? "s" : ""} from ${id} allowlist (now ${next.length ? next.join(", ") : "(allow all)"}) — takes effect immediately (hot-reloaded)`);
877
+ process.exit(0);
878
+ }
879
+ console.error(`usage: mslxdff -provider ${id} allowlist [list|set|add|remove|clear] [models...]`);
880
+ process.exit(1);
881
+ }
882
+ if (sub === "list" || sub === "status") {
883
+ const keys = loadProviderKeys(id);
884
+ const cfg = loadProviderConfig(id);
885
+ const { loadProviderAllowedModels } = await import("../src/state.js");
886
+ const allowed = loadProviderAllowedModels(id);
887
+ const baseUrl = cfg?.baseUrl || "";
888
+ if (baseUrl) console.log(`provider: ${id} baseUrl: ${baseUrl}`);
889
+ else console.log(`provider: ${id}${id === "openrouter" ? " (built-in baseUrl: https://openrouter.ai/api/v1)" : ""}`);
890
+ if (keys.length) {
891
+ console.log(` keys: ${keys.length} key${keys.length > 1 ? "s" : ""}`);
892
+ keys.forEach((k, i) => console.log(` [${i + 1}] ${k.slice(0, 4)}…${k.slice(-4)} (${k.length} chars)`));
893
+ console.log(` remove by: mslxdff -provider ${id} remove <seq> [seq...] | <key-value>`);
894
+ } else {
895
+ console.log(` keys: (no keys configured)`);
896
+ }
897
+ console.log(` share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"} (mslxdff -provider ${id} share on|off)`);
898
+ if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
899
+ else console.log(` allowedModels: (none — allow all) (mslxdff -provider ${id} allowlist set <model...>)`);
900
+ if (baseUrl) console.log(` set url: mslxdff -provider ${id} set-url <baseUrl>`);
901
+ console.log(` NOTE: opencode is the default provider and can never be shared`);
902
+ process.exit(0);
903
+ }
904
+ if (sub === "add") {
905
+ const key = rest[1];
906
+ if (!key) {
907
+ console.error("usage: mslxdff -provider openrouter add <key>");
908
+ process.exit(1);
909
+ }
910
+ // 通用供应商:keys 存 providerConfigs,需保留 baseUrl
911
+ const configs = loadProviderConfigs();
912
+ if (configs[id]) {
913
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
914
+ const keys = [...new Set([...(cur.keys || []), String(key).trim()].filter(Boolean))];
915
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
916
+ console.log(`added ${id} API key (now ${keys.length} total) — restart daemon to activate`);
917
+ } else {
918
+ const added = addProviderKey(id, key);
919
+ console.log(`added ${id} API key (now ${added.length} total) — restart daemon to activate`);
920
+ }
921
+ process.exit(0);
922
+ }
923
+ if (sub === "remove") {
924
+ const targets = rest.slice(1).filter((k) => !k.startsWith("-"));
925
+ if (!targets.length) {
926
+ console.error("usage: mslxdff -provider openrouter remove <seq|key> [seq|key ...] (seq = index shown by 'list')");
927
+ process.exit(1);
928
+ }
929
+ const current = loadProviderKeys(id);
930
+ const toRemove = [];
931
+ for (const raw of targets.flatMap((t) => String(t).split(","))) {
932
+ const t = raw.trim();
933
+ if (!t) continue;
934
+ if (/^\d+$/.test(t)) {
935
+ const seq = Number(t);
936
+ const idx = seq - 1;
937
+ if (Number.isInteger(seq) && idx >= 0 && idx < current.length) toRemove.push(current[idx]);
938
+ else console.log(` ! no key at sequence ${seq} (provider has ${current.length}) — skipped`);
939
+ } else {
940
+ toRemove.push(t);
941
+ }
942
+ }
943
+ if (!toRemove.length) {
944
+ console.log("nothing to remove");
945
+ process.exit(0);
946
+ }
947
+ const configs = loadProviderConfigs();
948
+ let remaining;
949
+ if (configs[id]) {
950
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
951
+ const set = new Set([...new Set(toRemove)].map((k) => String(k).trim()));
952
+ const keys = (cur.keys || []).filter((k) => !set.has(k));
953
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
954
+ remaining = keys;
955
+ } else {
956
+ remaining = removeProviderKeys(id, [...new Set(toRemove)]);
957
+ }
958
+ console.log(`removed ${current.length - remaining.length} ${id} API key(s) (now ${remaining.length} total) — restart daemon to activate`);
959
+ process.exit(0);
960
+ }
961
+ if (sub && !sub.startsWith("-")) {
962
+ const keys = rest.filter((k) => !k.startsWith("-"));
963
+ if (!keys.length) {
964
+ console.error("no key given");
965
+ process.exit(1);
966
+ }
967
+ const configs = loadProviderConfigs();
968
+ if (configs[id]) {
969
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
970
+ const clean = [...new Set(keys.map((k) => String(k).trim()).filter(Boolean))];
971
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys: clean });
972
+ console.log(`set ${id} API keys (${clean.length}: ${clean.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
973
+ } else {
974
+ saveProviderKeys(id, keys);
975
+ console.log(`set ${id} API keys (${keys.length}: ${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
976
+ }
977
+ process.exit(0);
978
+ }
979
+ // 交互式隐藏输入:多行直到空行,逐一追加到现有 keys
980
+ if (process.stdin.isTTY && process.stdout.isTTY) {
981
+ const readline = await import("node:readline/promises");
982
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
983
+ console.log(`Enter ${id} API keys, one per line (input hidden). Blank line to finish:`);
984
+ const existing = loadProviderKeys(id);
985
+ const collected = [];
986
+ for (;;) {
987
+ const key = await rl.question(existing.length || collected.length ? "" : "");
988
+ const clean = String(key || "").trim();
989
+ if (!clean) break;
990
+ collected.push(clean);
991
+ }
992
+ rl.close();
993
+ if (!collected.length) {
994
+ console.log("empty input — nothing changed");
995
+ process.exit(0);
996
+ }
997
+ for (const k of collected) addProviderKey(id, k);
998
+ const total = (await import("../src/state.js")).loadProviderKeys(id).length;
999
+ console.log(`added ${collected.length} ${id} API key(s) (now ${total} total) — restart daemon to activate`);
1000
+ process.exit(0);
1001
+ }
1002
+ console.error("provide keys inline (non-TTY): mslxdff -provider openrouter <key1> [key2 ...]");
1003
+ process.exit(1);
1004
+ }
1005
+
373
1006
  // 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
374
1007
  async function pickInteractive(items, startCursor = 0) {
375
1008
  const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
@@ -931,27 +1564,103 @@ if (loadedPlugins.length) {
931
1564
  console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
932
1565
  appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
933
1566
  }
1567
+ // 多供应商通道:默认 opencode + 可选 openrouter(MSLXDFF_OPENROUTER_KEY 启用)
1568
+ // 插件 createUpstream 仍是"整体替换式"外部 provider(单通道,保留原语义)
934
1569
  const upstreamHooks = loadedPlugins.length
935
1570
  ? (name, ctx) => runHook(loadedPlugins, name, ctx)
936
1571
  : null;
937
- // 插件可提供 createUpstream(ctx) 整体替换上游(接任意 provider);取第一个声明者
938
1572
  const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
939
1573
  let upstream;
1574
+ const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
1575
+ let providers = [];
940
1576
  if (providerPlugin) {
941
- try {
942
- upstream = await providerPlugin.createUpstream({ baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai", authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
943
- console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
944
- appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
945
- } catch (err) {
946
- console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} falling back to default`);
947
- appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
1577
+ // 插件整体替换:沿用旧单通道路径
1578
+ if (!upstream) {
1579
+ try {
1580
+ upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
1581
+ console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
1582
+ appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
1583
+ } catch (err) {
1584
+ console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
1585
+ appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
1586
+ }
1587
+ }
1588
+ if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
1589
+ } else {
1590
+ // 内置多 Provider:opencode 恒启用,openrouter 有 key 才启用
1591
+ const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
1592
+ const opencodeModels = createModelsService({
1593
+ baseUrl,
1594
+ headers: opencodeClient.headers,
1595
+ refreshMs: refreshIntervalMs(),
1596
+ cacheFile: join(logDir(), "models.json"),
1597
+ });
1598
+ providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
1599
+ const orKeys = loadProviderKeys("openrouter");
1600
+ if (orKeys.length) {
1601
+ const { createOpenRouterProvider } = await import("../src/providers/openrouter.js");
1602
+ providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
1603
+ console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
1604
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
1605
+ }
1606
+ // 通用 OpenAI 兼容供应商:读 providerConfigs,逐个创建 generic provider
1607
+ const { loadProviderConfigs } = await import("../src/state.js");
1608
+ const genericConfigs = loadProviderConfigs();
1609
+ for (const [gid, cfg] of Object.entries(genericConfigs)) {
1610
+ if (gid === "opencode" || gid === "openrouter") continue;
1611
+ if (gid === "workbuddy") {
1612
+ const base = String(cfg?.baseUrl || "").trim() || "https://copilot.tencent.com";
1613
+ const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
1614
+ const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
1615
+ if (!keys.length) continue;
1616
+ try {
1617
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1618
+ providers.push(createWorkbuddyProvider({ baseUrl: base, apiKeys: keys, auths }));
1619
+ console.log(`provider enabled: workbuddy (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
1620
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
1621
+ } catch (err) {
1622
+ console.log(`provider ${gid} failed: ${err?.message || err}`);
1623
+ appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
1624
+ }
1625
+ continue;
1626
+ }
1627
+ const base = String(cfg?.baseUrl || "").trim();
1628
+ const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
1629
+ if (!base || !keys.length) continue;
1630
+ try {
1631
+ const { createGenericProvider } = await import("../src/providers/generic.js");
1632
+ providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
1633
+ console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
1634
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
1635
+ } catch (err) {
1636
+ console.log(`provider ${gid} failed: ${err?.message || err}`);
1637
+ appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
1638
+ }
948
1639
  }
1640
+ // workbuddy via env-only (no providerConfigs entry but MSLXDFF_WORKBUDDY_KEY set)
1641
+ if (!genericConfigs["workbuddy"]) {
1642
+ const wbKeys = loadProviderKeys("workbuddy");
1643
+ if (wbKeys.length) {
1644
+ try {
1645
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1646
+ const wbAuths = loadProviderAuths("workbuddy");
1647
+ providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
1648
+ console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
1649
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
1650
+ } catch (err) {
1651
+ console.log(`provider workbuddy failed: ${err?.message || err}`);
1652
+ }
1653
+ }
1654
+ }
1655
+ const { createProviderDispatcher } = await import("../src/providers/dispatcher.js");
1656
+ upstream = createProviderDispatcher(providers);
1657
+ appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
949
1658
  }
950
- if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
951
- const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
1659
+ const opencodeProvider = providers.find((p) => p.id === "opencode");
952
1660
  const models = createModelsService({
1661
+ providers: providers.length > 1 ? providers : undefined,
953
1662
  baseUrl,
954
- headers: upstream.headers,
1663
+ headers: upstream.headers || opencodeProvider?.upstream?.headers,
955
1664
  refreshMs: refreshIntervalMs(),
956
1665
  cacheFile: join(logDir(), "models.json"),
957
1666
  });
@@ -1357,6 +2066,12 @@ Usage:
1357
2066
  mslxdff -showtoken print the current auth token
1358
2067
  mslxdff -refresh-token rotate the auth token (prints the new one)
1359
2068
  mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
2069
+ mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] add a generic OpenAI-compatible provider (myapi/gpt-4, baseUrl https://api.example.com/v1; extra models = allowlist)
2070
+ mslxdff -provider add workbuddy https://copilot.tencent.com <key> [allow...] add WorkBuddy专用供应商(workbuddy/hy3 前缀路由,auths 与 keys 一一对应)
2071
+ mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url] configure provider API keys/URL (multiple keys = rotating, set-url for generic)
2072
+ mslxdff -provider <id> allowlist [list|set|add|remove|clear] manage allowed models (empty=allow all, non-empty=only listed)
2073
+ mslxdff -workbuddy checkin daily 100 credits(双域幂等,已签 code 10001 视为成功)
2074
+ mslxdff -providers list list all configured upstream providers (opencode, openrouter, generic, workbuddy)
1360
2075
  mslxdff -creategroup <name> create a group on this node (the group name is the password)
1361
2076
  mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
1362
2077
  mslxdff -group sync pull the freshest member list for all joined groups
@@ -1365,6 +2080,7 @@ Usage:
1365
2080
  mslxdff -group remove <seq> leader only: kick a member by its list sequence number
1366
2081
  mslxdff -leavegroup leave every joined group as a member (leaders: use -delgroup)
1367
2082
  mslxdff -delgroup <name> disband a group this node leads (deletes it and its members)
2083
+ mslxdff -chat ["prompt"] chat REPL(mimo-v2.5-free 优先/big-pickle 兜底,自然语言转命令,模糊匹配由模型完成,历史持久化,超长自动压缩,仅拦 -uninstall,daemon 重启不影响)
1368
2084
  mslxdff -resetban [ip] clear join-failure bans (all, or one ip)
1369
2085
  mslxdff -help show this help
1370
2086
 
@@ -1374,6 +2090,7 @@ Environment:
1374
2090
  MSLXDFF_DAEMON_DIR daemon pid/log/models dir
1375
2091
  UPSTREAM_BASE_URL upstream base (default https://opencode.ai)
1376
2092
  UPSTREAM_AUTH_TOKEN upstream bearer value (default "public")
2093
+ MSLXDFF_OPENROUTER_KEY openrouter provider API keys(s) (single env value; multiple use "mslxdff -provider openrouter k1 k2 ..." to persist)
1377
2094
  UPSTREAM_CONNECT_TIMEOUT_MS upstream connect timeout (default 30000)
1378
2095
  MODELS_REFRESH_MS model-list background refresh interval (default 7200000)
1379
2096
  MSLXDFF_MODEL_COOLDOWN_MS fallback cooldown after a model error (default 60000)
@@ -1516,9 +2233,9 @@ async function printStatus() {
1516
2233
 
1517
2234
  function fmtStatus(id, statuses) {
1518
2235
  const e = statuses[id];
1519
- if (typeof e === "number") return `error ${fmtTs(new Date(e).toISOString())}`;
2236
+ if (typeof e === "number") return `error ${fmtTs(e)}`;
1520
2237
  if (!e?.status || e.status === "normal") return "";
1521
- const when = e.at ? ` ${fmtTs(new Date(e.at).toISOString())}` : "";
2238
+ const when = e.at ? ` ${fmtTs(e.at)}` : "";
1522
2239
  const code = e.code ? ` HTTP ${e.code}` : "";
1523
2240
  return `${e.status}${when}${code}`;
1524
2241
  }
@@ -1529,7 +2246,7 @@ function fmtDur(ms) {
1529
2246
  }
1530
2247
 
1531
2248
  function fmtEvent(e) {
1532
- const t = e?.ts ? new Date(e.ts).toISOString().slice(11, 19) : "--:--:--";
2249
+ const t = e?.ts ? fmtShanghaiHMS(e.ts) : "--:--:--";
1533
2250
  const head = `[${t}]`;
1534
2251
  const m = (x) => x || "-";
1535
2252
  const fallbackTag = e.fallback?.fallback ? ` fallback=${e.fallback.requested_model || e.requested}->${e.fallback.actual_model || e.actual}(${e.fallback.reason || e.reason})` : "";
@@ -1578,7 +2295,7 @@ function fmtEvent(e) {
1578
2295
  case "relay-forward":
1579
2296
  return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
1580
2297
  case "relay-heartbeat":
1581
- return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? new Date(e.lastSeen).toISOString().slice(11,19) : "?"}`;
2298
+ return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? fmtShanghaiHMS(e.lastSeen) : "?"}`;
1582
2299
  case "client-abort":
1583
2300
  return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
1584
2301
  case "result":
@@ -1617,12 +2334,7 @@ function fmtEvent(e) {
1617
2334
  }
1618
2335
 
1619
2336
  function fmtTs(iso) {
1620
- if (!iso) return "-";
1621
- try {
1622
- return new Date(iso).toISOString().replace("T", " ").slice(5, 19);
1623
- } catch {
1624
- return "-";
1625
- }
2337
+ return fmtShanghai(iso);
1626
2338
  }
1627
2339
 
1628
2340
  function npmCmd() {