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
package/bin/mslxdff.js CHANGED
@@ -8,7 +8,7 @@ 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";
@@ -19,6 +19,8 @@ import { createEventBus } from "../src/events.js";
19
19
  import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
20
20
  import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
21
21
  import { loadPlugins, runHook, pluginsDir, resolvePluginDirs } from "../src/plugins.js";
22
+ import { createOpenCodeProvider } from "../src/providers/opencode.js";
23
+ import { fmtShanghai, fmtShanghaiYMDHM, fmtShanghaiHMS } from "../src/time.js";
22
24
 
23
25
  const logs = { appendCall, appendError, appendEvent };
24
26
 
@@ -134,6 +136,17 @@ if (args.includes("-plugins") || args.includes("--plugins")) {
134
136
  process.exit(0);
135
137
  }
136
138
 
139
+ if (args.includes("-chat") || args.includes("--chat")) {
140
+ const idx = args.findIndex((x) => x === "-chat" || x === "--chat");
141
+ const rest = args.slice(idx + 1);
142
+ // rest 中若全是空白或以 - 开头的“选项”,仍视作本次对话的输入(单次模式);
143
+ // 无 rest 则进入常驻 REPL。REPL 本身就是前台独立进程,与 daemon 无父子关系,daemon 重启不影响它。
144
+ const singleShot = rest.length ? rest.join(" ").trim() : null;
145
+ const { startChat } = await import("../src/chat/index.js");
146
+ await startChat({ singleShot: singleShot || undefined });
147
+ process.exit(0);
148
+ }
149
+
137
150
  if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
138
151
  await printStatus();
139
152
  process.exit(0);
@@ -174,9 +187,7 @@ if (args.includes("-model") || args.includes("-models")) {
174
187
  const e = statuses[id];
175
188
  const st = typeof e === "number" ? "error" : e?.status || "normal";
176
189
  const at = typeof e === "number" ? e : e?.at;
177
- const when = at
178
- ? ` (${new Date(at).toISOString().slice(5, 19).replace("T", " ")})`
179
- : "";
190
+ const when = at ? ` (${fmtShanghai(at)})` : "";
180
191
  const extra = e?.code ? ` HTTP ${e.code}` : "";
181
192
  console.log(` ${id} ${st}${when}${extra}`);
182
193
  }
@@ -289,7 +300,7 @@ if (args.includes("-model") || args.includes("-models")) {
289
300
  console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
290
301
  process.exit(0);
291
302
  }
292
- const at = cachedAt ? ` (cached ${new Date(cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
303
+ const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
293
304
  const pickedIds = loadModelPicks();
294
305
  const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
295
306
  console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
@@ -370,6 +381,556 @@ if (args.includes("-setto") || args.includes("--setto")) {
370
381
  process.exit(0);
371
382
  }
372
383
 
384
+ // -workbuddy checkin/balance/list/remove : WorkBuddy 多号管理
385
+ if (args.includes("-workbuddy") || args.includes("--workbuddy") || args.includes("-wb")) {
386
+ const idx = args.findIndex((x) => x === "-workbuddy" || x === "--workbuddy" || x === "-wb");
387
+ const sub = args[idx + 1];
388
+ if (!sub || sub === "checkin" || sub === "daily-checkin" || sub === "check-in") {
389
+ const { spawn } = await import("node:child_process");
390
+ const script = join(dirname(fileURLToPath(import.meta.url)), "..", "workbuddy-checkin.js");
391
+ const child = spawn(process.execPath, [script, ...args.slice(idx + 2)], { stdio: "inherit" });
392
+ child.on("close", (code) => process.exit(code ?? 0));
393
+ child.on("error", (err) => { console.error(`workbuddy checkin failed: ${err.message}`); process.exit(1); });
394
+ await new Promise(() => {});
395
+ } else if (sub === "balance" || sub === "balances" || sub === "credit" || sub === "credits") {
396
+ const asJson = args.includes("--json") || args.includes("-json");
397
+ const { loadProviderConfigs } = await import("../src/state.js");
398
+ const { fetchBalance } = await import("../src/providers/workbuddy-balance.js");
399
+ const cfg = loadProviderConfigs().workbuddy || {};
400
+ const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
401
+ const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
402
+ // fallback scan auths dir if state empty
403
+ let items = auths.map((a,i)=> ({ uid:a.uid, domain:a.domain, key: keys[i]||"" , auth:a }));
404
+ if (!items.length) {
405
+ try {
406
+ const { readdirSync, readFileSync, existsSync } = await import("node:fs");
407
+ const { join } = await import("node:path");
408
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
409
+ if (existsSync(dir)) {
410
+ for (const f of readdirSync(dir).filter(x=>x.startsWith("workbuddy-")&&x.endsWith(".json"))) {
411
+ 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 {}
412
+ }
413
+ }
414
+ } catch {}
415
+ }
416
+ if (!items.length) { console.log("no workbuddy accounts — run node workbuddy-token-auto.js"); process.exit(0); }
417
+ const results=[];
418
+ for (const it of items) {
419
+ const b = await fetchBalance({ uid: it.uid, key: it.key, auth: it.auth }).catch(()=>null);
420
+ results.push({ uid: it.uid, domain: it.domain, balance: b });
421
+ }
422
+ if (asJson) { console.log(JSON.stringify({ results }, null, 2)); process.exit(0); }
423
+ console.log(`workbuddy balances (${results.length}):`);
424
+ for (const r of results) {
425
+ const b=r.balance;
426
+ if (!b) console.log(` ${r.uid} (balance unavailable) domain=${r.domain}`);
427
+ else console.log(` ${r.uid} total=${b.totalStr||b.total} dailyPacks=${b.dailyPacks} active=${b.activeCount} nextExpire=${b.nextExpire||"-"} domain=${r.domain}`);
428
+ }
429
+ process.exit(0);
430
+ } else if (sub === "list" || sub === "ls" || sub === "status") {
431
+ const { loadProviderConfigs } = await import("../src/state.js");
432
+ const cfg = loadProviderConfigs().workbuddy || {};
433
+ const auths = Array.isArray(cfg.auths) ? cfg.auths : [];
434
+ const keys = Array.isArray(cfg.keys) ? cfg.keys : [];
435
+ if (!auths.length) { console.log("no workbuddy accounts in state — check auths/workbuddy-*.json"); process.exit(0); }
436
+ console.log(`workbuddy accounts (${auths.length}):`);
437
+ auths.forEach((a,i)=> {
438
+ const k=(keys[i]||"").slice(0,4);
439
+ 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"}`);
440
+ });
441
+ process.exit(0);
442
+ } else if (sub === "remove" || sub === "rm" || sub === "del" || sub === "delete") {
443
+ const target = args[idx+2];
444
+ if (!target) { console.error("usage: mslxdff -workbuddy remove <uid> [--keep-file]"); process.exit(1); }
445
+ const keep = args.includes("--keep-file");
446
+ const { loadProviderConfigs, saveProviderConfig } = await import("../src/state.js");
447
+ const cfg = loadProviderConfigs().workbuddy || {};
448
+ let auths = Array.isArray(cfg.auths)? [...cfg.auths]:[];
449
+ let keys = Array.isArray(cfg.keys)? [...cfg.keys]:[];
450
+ // support prefix match 6 chars
451
+ let rmIdx = auths.findIndex(a=> a.uid===target || a.uid.startsWith(target));
452
+ if (rmIdx<0) { console.error(`uid not found: ${target}`); process.exit(1); }
453
+ const uid = auths[rmIdx].uid;
454
+ auths.splice(rmIdx,1); keys.splice(rmIdx,1);
455
+ saveProviderConfig("workbuddy", { baseUrl: cfg.baseUrl||"https://copilot.tencent.com", keys, auths });
456
+ if (!keep) {
457
+ try {
458
+ const { existsSync, unlinkSync } = await import("node:fs");
459
+ const { join } = await import("node:path");
460
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
461
+ const fp = join(dir, `workbuddy-${uid}.json`);
462
+ if (existsSync(fp)) { unlinkSync(fp); console.log(`removed file ${fp}`); }
463
+ } catch {}
464
+ }
465
+ try { const { getBalanceCache } = await import("../src/providers/workbuddy-balance.js"); getBalanceCache().delete(uid); } catch {}
466
+ console.log(`removed workbuddy ${uid} (now ${auths.length} account(s))`);
467
+ process.exit(0);
468
+ } else {
469
+ console.error("usage: mslxdff -workbuddy checkin | balance [--json] | list | remove <uid>");
470
+ process.exit(1);
471
+ }
472
+ }
473
+
474
+ // -providers list : list all deployed upstream providers (opencode + openrouter + generic)
475
+ if (args.includes("-providers") || args.includes("--providers")) {
476
+ const idx = args.findIndex((x) => x === "-providers" || x === "--providers");
477
+ const sub = args[idx + 1];
478
+ if (!sub || sub === "list" || sub === "status") {
479
+ const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl, loadProviderAllowedModels } = await import("../src/state.js");
480
+ const configs = loadProviderConfigs();
481
+ const opencodeEnabled = true;
482
+ const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
483
+ const orKeys = loadProviderKeys("openrouter");
484
+ const orBase = "https://openrouter.ai/api/v1";
485
+ const orShare = loadProviderShareKeys("openrouter");
486
+ const orAllowed = loadProviderAllowedModels("openrouter");
487
+ // collect generic ids from configs + legacy providerKeys
488
+ const genericIds = new Set(Object.keys(configs).filter((id) => id !== "opencode" && id !== "openrouter"));
489
+ try {
490
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
491
+ const pk = raw.providerKeys || {};
492
+ for (const id of Object.keys(pk)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
493
+ const cfgRaw = raw.providerConfigs || {};
494
+ for (const id of Object.keys(cfgRaw)) if (id !== "opencode" && id !== "openrouter") genericIds.add(id);
495
+ } catch {}
496
+ // also include env-only generic ids (scan env for MSLXDFF_*_KEY)
497
+ for (const k of Object.keys(process.env)) {
498
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
499
+ if (m) {
500
+ const id = m[1].toLowerCase().replace(/__/g, "-");
501
+ if (id !== "openrouter" && id !== "opencode") genericIds.add(id);
502
+ }
503
+ }
504
+ // include providers that only have allowlist (no keys yet) — exclude built-ins already listed
505
+ try {
506
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
507
+ const cfgs = raw.providerConfigs || {};
508
+ for (const id of Object.keys(cfgs)) {
509
+ if (id === "opencode" || id === "openrouter") continue;
510
+ const am = cfgs[id]?.allowedModels;
511
+ if (Array.isArray(am) && am.length) genericIds.add(id);
512
+ }
513
+ } catch {}
514
+ const list = [];
515
+ const opAllowed = loadProviderAllowedModels("opencode");
516
+ list.push({ id: "opencode", enabled: opencodeEnabled, baseUrl: opencodeBase, keys: [], share: false, allowed: opAllowed, note: "built-in, no key, cannot share" });
517
+ list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, allowed: orAllowed, note: orKeys.length ? "" : "no keys" });
518
+ for (const gid of [...genericIds].sort()) {
519
+ const cfg = configs[gid];
520
+ const keys = loadProviderKeys(gid);
521
+ const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
522
+ const share = loadProviderShareKeys(gid);
523
+ const allowed = loadProviderAllowedModels(gid);
524
+ const enabled = Boolean(baseUrl && keys.length);
525
+ let note = "";
526
+ if (!baseUrl && !keys.length && !allowed.length) note = "no baseUrl, no keys";
527
+ else if (!baseUrl && !allowed.length) note = "missing baseUrl";
528
+ else if (!keys.length && !allowed.length) note = "no keys";
529
+ else if (!baseUrl) note = "missing baseUrl";
530
+ else if (!keys.length) note = "no keys";
531
+ list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, allowed, note });
532
+ }
533
+ console.log(`providers (${list.length}):`);
534
+ for (const p of list) {
535
+ const state = p.enabled ? "enabled " : "disabled";
536
+ 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";
537
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
538
+ const allowInfo = p.allowed.length ? `allow=${p.allowed.length}(${p.allowed.slice(0, 3).join(",")}${p.allowed.length > 3 ? "..." : ""})` : "allow=all";
539
+ const note = p.note ? ` (${p.note})` : "";
540
+ console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} ${allowInfo.padEnd(22)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
541
+ }
542
+ console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider <id> allowlist set <model...> to restrict`);
543
+ process.exit(0);
544
+ }
545
+ console.error("usage: mslxdff -providers list");
546
+ process.exit(1);
547
+ }
548
+
549
+ // -provider <id> [key...|add|remove|list|clear]: 配置需鉴权的供应商 API key(如 openrouter)。
550
+ // 行为:无参数且 TTY → 交互式隐藏输入(多行直到空行,逐一追加);非 TTY → 打印用法。key 持久化到 state。
551
+ if (args.includes("-provider") || args.includes("--provider")) {
552
+ const idx = args.findIndex((x) => x === "-provider" || x === "--provider");
553
+ const id = args[idx + 1];
554
+ const sub = args[idx + 2];
555
+ const rest = args.slice(idx + 2);
556
+ if (!id) {
557
+ console.error("usage: mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url|allowlist]");
558
+ console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
559
+ console.error(" mslxdff -provider openrouter add sk-4 append one key");
560
+ console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
561
+ console.error(" mslxdff -provider openrouter list list all keys (masked)");
562
+ console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
563
+ console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
564
+ console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=allow all)");
565
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
566
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
567
+ console.error(" mslxdff -provider openrouter interactive hidden input (append)");
568
+ console.error(" mslxdff -provider openrouter clear remove all keys");
569
+ process.exit(1);
570
+ }
571
+ // alias: mslxdff -provider list (same as -providers list) — list all providers
572
+ if (id === "list" || id === "status") {
573
+ const { loadProviderConfigs, loadProviderKeys, loadProviderShareKeys, loadProviderBaseUrl } = await import("../src/state.js");
574
+ const configs = loadProviderConfigs();
575
+ const opencodeBase = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
576
+ const orKeys = loadProviderKeys("openrouter");
577
+ const orBase = "https://openrouter.ai/api/v1";
578
+ const orShare = loadProviderShareKeys("openrouter");
579
+ const genericIds = new Set(Object.keys(configs).filter((x) => x !== "opencode" && x !== "openrouter"));
580
+ try {
581
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
582
+ const pk = raw.providerKeys || {};
583
+ for (const k of Object.keys(pk)) if (k !== "opencode" && k !== "openrouter") genericIds.add(k);
584
+ } catch {}
585
+ for (const k of Object.keys(process.env)) {
586
+ const m = k.match(/^MSLXDFF_(.+)_KEY$/);
587
+ if (m) {
588
+ const gid = m[1].toLowerCase().replace(/__/g, "-");
589
+ if (gid !== "openrouter" && gid !== "opencode") genericIds.add(gid);
590
+ }
591
+ }
592
+ const list = [];
593
+ list.push({ id: "opencode", enabled: true, baseUrl: opencodeBase, keys: [], share: false, note: "built-in, no key, cannot share" });
594
+ list.push({ id: "openrouter", enabled: orKeys.length > 0, baseUrl: orBase, keys: orKeys, share: orShare, note: orKeys.length ? "" : "no keys" });
595
+ for (const gid of [...genericIds].sort()) {
596
+ const cfg = configs[gid];
597
+ const keys = loadProviderKeys(gid);
598
+ const baseUrl = loadProviderBaseUrl(gid) || cfg?.baseUrl || "";
599
+ const share = loadProviderShareKeys(gid);
600
+ const enabled = Boolean(baseUrl && keys.length);
601
+ let note = "";
602
+ if (!baseUrl && !keys.length) note = "no baseUrl, no keys";
603
+ else if (!baseUrl) note = "missing baseUrl";
604
+ else if (!keys.length) note = "no keys";
605
+ list.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, share, note });
606
+ }
607
+ console.log(`providers (${list.length}):`);
608
+ for (const p of list) {
609
+ const state = p.enabled ? "enabled " : "disabled";
610
+ 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";
611
+ const shareInfo = p.id === "opencode" ? "cannot share" : `share=${p.share ? "ON" : "off"}`;
612
+ const note = p.note ? ` (${p.note})` : "";
613
+ console.log(` ${p.id.padEnd(12)} ${state} ${keysInfo.padEnd(28)} baseUrl=${p.baseUrl} ${shareInfo}${note}`);
614
+ }
615
+ console.log(`\nuse: mslxdff -provider <id> list to inspect one, mslxdff -provider add <id> <baseUrl> <key> to add generic`);
616
+ process.exit(0);
617
+ }
618
+ // 通用供应商快捷添加:mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]
619
+ if (id === "add") {
620
+ const gid = sub;
621
+ const gBase = rest[1];
622
+ const gKey = rest[2];
623
+ if (!gid || !gBase || !gKey) {
624
+ console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]");
625
+ console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
626
+ console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
627
+ process.exit(1);
628
+ }
629
+ if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
630
+ console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
631
+ process.exit(1);
632
+ }
633
+ const { saveProviderConfig, loadProviderConfig, loadProviderShareKeys, loadProviderAllowedModels } = await import("../src/state.js");
634
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
635
+ const nid = normalizeProviderId(gid);
636
+ if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
637
+ if (!/^https?:\/\/.+/.test(String(gBase).trim())) { console.error(`invalid baseUrl: ${gBase} (must start with http:// or https://)`); process.exit(1); }
638
+ const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [] };
639
+ let keys, auths, baseUrl;
640
+ baseUrl = String(gBase).trim();
641
+ const extraModels = rest.slice(3).filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
642
+ const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
643
+ if (nid === "workbuddy") {
644
+ // workbuddy: keys/auths 一一对应,需解析 uid
645
+ const token = String(gKey).trim();
646
+ let uid = "";
647
+ try { const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString()); uid = payload.uid || payload.sub || payload.userId || payload.user_id || ""; } catch {}
648
+ if (!uid) uid = "manual-" + token.slice(-8);
649
+ const curKeys = Array.isArray(cur.keys) ? [...cur.keys] : [];
650
+ const curAuths = Array.isArray(cur.auths) ? [...cur.auths] : [];
651
+ let idx = curAuths.findIndex(a => a.uid === uid);
652
+ if (idx < 0) idx = curKeys.findIndex(k => k === token);
653
+ let newKeys, newAuths;
654
+ if (idx >= 0) {
655
+ newKeys = [...curKeys]; newKeys[idx] = token;
656
+ newAuths = [...curAuths]; newAuths[idx] = { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
657
+ // if auths shorter than keys, pad
658
+ while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
659
+ } else {
660
+ newKeys = [...new Set([...curKeys, token].filter(Boolean))];
661
+ newAuths = [...curAuths, { uid, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" }];
662
+ // align lengths
663
+ while (newAuths.length < newKeys.length) newAuths.push({ uid: `manual-${newKeys[newAuths.length].slice(-8)}`, domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" });
664
+ while (newKeys.length < newAuths.length) newKeys.push(token);
665
+ }
666
+ keys = newKeys; auths = newAuths;
667
+ saveProviderConfig(nid, { baseUrl, keys, auths, allowedModels });
668
+ // 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
669
+ try {
670
+ const { writeFileSync, mkdirSync } = await import("node:fs");
671
+ const { join } = await import("node:path");
672
+ const dir = process.env.WORKBUDDY_AUTH_DIR || join(process.cwd(), "auths");
673
+ mkdirSync(dir, { recursive: true });
674
+ const fp = join(dir, `workbuddy-${uid}.json`);
675
+ const doc = { account: { uid, enterpriseId: "", nickname: "" }, auth: { accessToken: token, refreshToken: "", expiresAt: Math.floor(Date.now()/1000)+3600, domain: "www.codebuddy.cn" } };
676
+ writeFileSync(fp + ".tmp", JSON.stringify(doc, null, 2), { mode: 0o600 });
677
+ 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 }); }
678
+ } catch {}
679
+ } else {
680
+ keys = [...new Set([...(cur.keys || []), String(gKey).trim()].filter(Boolean))];
681
+ auths = undefined;
682
+ saveProviderConfig(nid, { baseUrl, keys, allowedModels });
683
+ }
684
+ console.log(`added generic provider: ${nid}`);
685
+ console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
686
+ console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
687
+ if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
688
+ else console.log(` allowedModels: (none — allow all, set via: mslxdff -provider ${nid} allowlist set <model...>)`);
689
+ console.log(` share: ${loadProviderShareKeys(nid) ? "ON" : "off"} (mslxdff -provider ${nid} share on|off)`);
690
+ console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
691
+ process.exit(0);
692
+ }
693
+ if ((id === "opencode" || id === "oc") && sub !== "allowlist" && sub !== "allow" && sub !== "allowed" && sub !== "whitelist" && sub !== "list" && sub !== "status") {
694
+ console.log("opencode is the default (bare) provider — it needs no API key and can never be shared with peers");
695
+ console.log("(its IP-based rate limit is spread by peer forwarding itself)");
696
+ console.log(` allowlist: mslxdff -provider opencode allowlist [list|set|add|remove|clear] — restrict models (empty=allow all)`);
697
+ process.exit(0);
698
+ }
699
+ const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
700
+ if (sub === "clear") {
701
+ const configs = loadProviderConfigs();
702
+ if (configs[id]) {
703
+ saveProviderConfig(id, { baseUrl: "", keys: [] });
704
+ } else {
705
+ saveProviderKeys(id, []);
706
+ }
707
+ console.log(`cleared ${id} API keys (provider disabled on next daemon start)`);
708
+ process.exit(0);
709
+ }
710
+ if (sub === "set-url" || sub === "setUrl" || sub === "url") {
711
+ const url = rest[1];
712
+ if (!url) {
713
+ console.error(`usage: mslxdff -provider ${id} set-url <baseUrl>`);
714
+ process.exit(1);
715
+ }
716
+ if (!/^https?:\/\/.+/.test(String(url).trim())) {
717
+ console.error(`invalid baseUrl: ${url} (must start with http:// or https://)`);
718
+ process.exit(1);
719
+ }
720
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
721
+ saveProviderConfig(id, { baseUrl: String(url).trim(), keys: cur.keys || [] });
722
+ console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
723
+ process.exit(0);
724
+ }
725
+ if (sub === "share") {
726
+ const on = rest[1];
727
+ if (!on) {
728
+ console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
729
+ process.exit(0);
730
+ }
731
+ if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
732
+ console.error("usage: mslxdff -provider openrouter share on|off");
733
+ process.exit(1);
734
+ }
735
+ const state = ["on", "1", "true"].includes(String(on).toLowerCase());
736
+ saveProviderShareKeys(id, state);
737
+ console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
738
+ process.exit(0);
739
+ }
740
+ // allowlist 管理:白名单为空 = 不限;非空 = 仅名单内可用(防昂贵模型)
741
+ if (sub === "allowlist" || sub === "allow" || sub === "allowed" || sub === "whitelist") {
742
+ const { loadProviderAllowedModels, saveProviderAllowedModels, loadProviderConfig } = await import("../src/state.js");
743
+ const action = rest[1];
744
+ const rawTargets = rest.slice(2);
745
+ // 兼容:mslxdff -provider <id> allowlist (无 action)→ list
746
+ if (!action || action === "list" || action === "status") {
747
+ const list = loadProviderAllowedModels(id);
748
+ const cfg = loadProviderConfig(id);
749
+ const baseUrl = cfg?.baseUrl || "";
750
+ console.log(`provider: ${id}${baseUrl ? ` baseUrl: ${baseUrl}` : ""}`);
751
+ if (!list.length) {
752
+ console.log(` allowedModels: (none — allow all)`);
753
+ console.log(` set via: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
754
+ console.log(` add: mslxdff -provider ${id} allowlist add <model>`);
755
+ } else {
756
+ console.log(` allowedModels: ${list.length} model${list.length > 1 ? "s" : ""} (only these can be used)`);
757
+ list.forEach((m, i) => console.log(` [${i + 1}] ${m}`));
758
+ console.log(` manage: mslxdff -provider ${id} allowlist add <model> | remove <model> | set <m1> <m2> ... | clear`);
759
+ }
760
+ console.log(` NOTE: empty allowlist = allow all (hot-reloaded, no restart needed)`);
761
+ process.exit(0);
762
+ }
763
+ if (action === "clear") {
764
+ saveProviderAllowedModels(id, []);
765
+ console.log(`cleared ${id} allowlist (now allow all) — takes effect immediately (hot-reloaded)`);
766
+ process.exit(0);
767
+ }
768
+ if (action === "set") {
769
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
770
+ // 支持逗号分隔的一串:model1,model2
771
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
772
+ if (!flat.length) {
773
+ console.error(`usage: mslxdff -provider ${id} allowlist set <model1> <model2> ...`);
774
+ process.exit(1);
775
+ }
776
+ const saved = saveProviderAllowedModels(id, flat);
777
+ console.log(`set ${id} allowlist: ${saved.length} model${saved.length > 1 ? "s" : ""} (${saved.join(", ")}) — takes effect immediately (hot-reloaded)`);
778
+ process.exit(0);
779
+ }
780
+ if (action === "add") {
781
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
782
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
783
+ if (!flat.length) {
784
+ console.error(`usage: mslxdff -provider ${id} allowlist add <model> [model2 ...]`);
785
+ process.exit(1);
786
+ }
787
+ const cur = loadProviderAllowedModels(id);
788
+ const next = [...new Set([...cur, ...flat])];
789
+ saveProviderAllowedModels(id, next);
790
+ console.log(`added ${flat.length} model${flat.length > 1 ? "s" : ""} to ${id} allowlist (now ${next.length}: ${next.join(", ")}) — takes effect immediately (hot-reloaded)`);
791
+ process.exit(0);
792
+ }
793
+ if (action === "remove" || action === "rm" || action === "del") {
794
+ const models = rawTargets.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
795
+ const flat = models.flatMap((m) => String(m).split(",")).map((m) => m.trim()).filter(Boolean);
796
+ if (!flat.length) {
797
+ console.error(`usage: mslxdff -provider ${id} allowlist remove <model> [model2 ...]`);
798
+ process.exit(1);
799
+ }
800
+ const cur = loadProviderAllowedModels(id);
801
+ const set = new Set(flat);
802
+ const next = cur.filter((m) => !set.has(m));
803
+ saveProviderAllowedModels(id, next);
804
+ 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)`);
805
+ process.exit(0);
806
+ }
807
+ console.error(`usage: mslxdff -provider ${id} allowlist [list|set|add|remove|clear] [models...]`);
808
+ process.exit(1);
809
+ }
810
+ if (sub === "list" || sub === "status") {
811
+ const keys = loadProviderKeys(id);
812
+ const cfg = loadProviderConfig(id);
813
+ const { loadProviderAllowedModels } = await import("../src/state.js");
814
+ const allowed = loadProviderAllowedModels(id);
815
+ const baseUrl = cfg?.baseUrl || "";
816
+ if (baseUrl) console.log(`provider: ${id} baseUrl: ${baseUrl}`);
817
+ else console.log(`provider: ${id}${id === "openrouter" ? " (built-in baseUrl: https://openrouter.ai/api/v1)" : ""}`);
818
+ if (keys.length) {
819
+ console.log(` keys: ${keys.length} key${keys.length > 1 ? "s" : ""}`);
820
+ keys.forEach((k, i) => console.log(` [${i + 1}] ${k.slice(0, 4)}…${k.slice(-4)} (${k.length} chars)`));
821
+ console.log(` remove by: mslxdff -provider ${id} remove <seq> [seq...] | <key-value>`);
822
+ } else {
823
+ console.log(` keys: (no keys configured)`);
824
+ }
825
+ console.log(` share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"} (mslxdff -provider ${id} share on|off)`);
826
+ if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
827
+ else console.log(` allowedModels: (none — allow all) (mslxdff -provider ${id} allowlist set <model...>)`);
828
+ if (baseUrl) console.log(` set url: mslxdff -provider ${id} set-url <baseUrl>`);
829
+ console.log(` NOTE: opencode is the default provider and can never be shared`);
830
+ process.exit(0);
831
+ }
832
+ if (sub === "add") {
833
+ const key = rest[1];
834
+ if (!key) {
835
+ console.error("usage: mslxdff -provider openrouter add <key>");
836
+ process.exit(1);
837
+ }
838
+ // 通用供应商:keys 存 providerConfigs,需保留 baseUrl
839
+ const configs = loadProviderConfigs();
840
+ if (configs[id]) {
841
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
842
+ const keys = [...new Set([...(cur.keys || []), String(key).trim()].filter(Boolean))];
843
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
844
+ console.log(`added ${id} API key (now ${keys.length} total) — restart daemon to activate`);
845
+ } else {
846
+ const added = addProviderKey(id, key);
847
+ console.log(`added ${id} API key (now ${added.length} total) — restart daemon to activate`);
848
+ }
849
+ process.exit(0);
850
+ }
851
+ if (sub === "remove") {
852
+ const targets = rest.slice(1).filter((k) => !k.startsWith("-"));
853
+ if (!targets.length) {
854
+ console.error("usage: mslxdff -provider openrouter remove <seq|key> [seq|key ...] (seq = index shown by 'list')");
855
+ process.exit(1);
856
+ }
857
+ const current = loadProviderKeys(id);
858
+ const toRemove = [];
859
+ for (const raw of targets.flatMap((t) => String(t).split(","))) {
860
+ const t = raw.trim();
861
+ if (!t) continue;
862
+ if (/^\d+$/.test(t)) {
863
+ const seq = Number(t);
864
+ const idx = seq - 1;
865
+ if (Number.isInteger(seq) && idx >= 0 && idx < current.length) toRemove.push(current[idx]);
866
+ else console.log(` ! no key at sequence ${seq} (provider has ${current.length}) — skipped`);
867
+ } else {
868
+ toRemove.push(t);
869
+ }
870
+ }
871
+ if (!toRemove.length) {
872
+ console.log("nothing to remove");
873
+ process.exit(0);
874
+ }
875
+ const configs = loadProviderConfigs();
876
+ let remaining;
877
+ if (configs[id]) {
878
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
879
+ const set = new Set([...new Set(toRemove)].map((k) => String(k).trim()));
880
+ const keys = (cur.keys || []).filter((k) => !set.has(k));
881
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys });
882
+ remaining = keys;
883
+ } else {
884
+ remaining = removeProviderKeys(id, [...new Set(toRemove)]);
885
+ }
886
+ console.log(`removed ${current.length - remaining.length} ${id} API key(s) (now ${remaining.length} total) — restart daemon to activate`);
887
+ process.exit(0);
888
+ }
889
+ if (sub && !sub.startsWith("-")) {
890
+ const keys = rest.filter((k) => !k.startsWith("-"));
891
+ if (!keys.length) {
892
+ console.error("no key given");
893
+ process.exit(1);
894
+ }
895
+ const configs = loadProviderConfigs();
896
+ if (configs[id]) {
897
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
898
+ const clean = [...new Set(keys.map((k) => String(k).trim()).filter(Boolean))];
899
+ saveProviderConfig(id, { baseUrl: cur.baseUrl || "", keys: clean });
900
+ console.log(`set ${id} API keys (${clean.length}: ${clean.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
901
+ } else {
902
+ saveProviderKeys(id, keys);
903
+ console.log(`set ${id} API keys (${keys.length}: ${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")}) — restart daemon to activate`);
904
+ }
905
+ process.exit(0);
906
+ }
907
+ // 交互式隐藏输入:多行直到空行,逐一追加到现有 keys
908
+ if (process.stdin.isTTY && process.stdout.isTTY) {
909
+ const readline = await import("node:readline/promises");
910
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
911
+ console.log(`Enter ${id} API keys, one per line (input hidden). Blank line to finish:`);
912
+ const existing = loadProviderKeys(id);
913
+ const collected = [];
914
+ for (;;) {
915
+ const key = await rl.question(existing.length || collected.length ? "" : "");
916
+ const clean = String(key || "").trim();
917
+ if (!clean) break;
918
+ collected.push(clean);
919
+ }
920
+ rl.close();
921
+ if (!collected.length) {
922
+ console.log("empty input — nothing changed");
923
+ process.exit(0);
924
+ }
925
+ for (const k of collected) addProviderKey(id, k);
926
+ const total = (await import("../src/state.js")).loadProviderKeys(id).length;
927
+ console.log(`added ${collected.length} ${id} API key(s) (now ${total} total) — restart daemon to activate`);
928
+ process.exit(0);
929
+ }
930
+ console.error("provide keys inline (non-TTY): mslxdff -provider openrouter <key1> [key2 ...]");
931
+ process.exit(1);
932
+ }
933
+
373
934
  // 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
374
935
  async function pickInteractive(items, startCursor = 0) {
375
936
  const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
@@ -931,27 +1492,103 @@ if (loadedPlugins.length) {
931
1492
  console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
932
1493
  appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
933
1494
  }
1495
+ // 多供应商通道:默认 opencode + 可选 openrouter(MSLXDFF_OPENROUTER_KEY 启用)
1496
+ // 插件 createUpstream 仍是"整体替换式"外部 provider(单通道,保留原语义)
934
1497
  const upstreamHooks = loadedPlugins.length
935
1498
  ? (name, ctx) => runHook(loadedPlugins, name, ctx)
936
1499
  : null;
937
- // 插件可提供 createUpstream(ctx) 整体替换上游(接任意 provider);取第一个声明者
938
1500
  const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
939
1501
  let upstream;
1502
+ const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
1503
+ let providers = [];
940
1504
  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) });
1505
+ // 插件整体替换:沿用旧单通道路径
1506
+ if (!upstream) {
1507
+ try {
1508
+ upstream = await providerPlugin.createUpstream({ baseUrl, authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
1509
+ console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
1510
+ appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
1511
+ } catch (err) {
1512
+ console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
1513
+ appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
1514
+ }
948
1515
  }
1516
+ if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
1517
+ } else {
1518
+ // 内置多 Provider:opencode 恒启用,openrouter 有 key 才启用
1519
+ const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
1520
+ const opencodeModels = createModelsService({
1521
+ baseUrl,
1522
+ headers: opencodeClient.headers,
1523
+ refreshMs: refreshIntervalMs(),
1524
+ cacheFile: join(logDir(), "models.json"),
1525
+ });
1526
+ providers.push(createOpenCodeProvider({ upstream: opencodeClient, modelsService: opencodeModels }));
1527
+ const orKeys = loadProviderKeys("openrouter");
1528
+ if (orKeys.length) {
1529
+ const { createOpenRouterProvider } = await import("../src/providers/openrouter.js");
1530
+ providers.push(createOpenRouterProvider({ apiKeys: orKeys }));
1531
+ console.log(`provider enabled: openrouter (${orKeys.length} key${orKeys.length > 1 ? "s" : ""})`);
1532
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
1533
+ }
1534
+ // 通用 OpenAI 兼容供应商:读 providerConfigs,逐个创建 generic provider
1535
+ const { loadProviderConfigs } = await import("../src/state.js");
1536
+ const genericConfigs = loadProviderConfigs();
1537
+ for (const [gid, cfg] of Object.entries(genericConfigs)) {
1538
+ if (gid === "opencode" || gid === "openrouter") continue;
1539
+ if (gid === "workbuddy") {
1540
+ const base = String(cfg?.baseUrl || "").trim() || "https://copilot.tencent.com";
1541
+ const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
1542
+ const auths = Array.isArray(cfg?.auths) ? cfg.auths : [];
1543
+ if (!keys.length) continue;
1544
+ try {
1545
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1546
+ providers.push(createWorkbuddyProvider({ baseUrl: base, apiKeys: keys, auths }));
1547
+ console.log(`provider enabled: workbuddy (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
1548
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
1549
+ } catch (err) {
1550
+ console.log(`provider ${gid} failed: ${err?.message || err}`);
1551
+ appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
1552
+ }
1553
+ continue;
1554
+ }
1555
+ const base = String(cfg?.baseUrl || "").trim();
1556
+ const keys = Array.isArray(cfg?.keys) ? cfg.keys.filter((k) => typeof k === "string" && k.trim()) : [];
1557
+ if (!base || !keys.length) continue;
1558
+ try {
1559
+ const { createGenericProvider } = await import("../src/providers/generic.js");
1560
+ providers.push(createGenericProvider({ id: gid, baseUrl: base, apiKeys: keys }));
1561
+ console.log(`provider enabled: ${gid} (${keys.length} key${keys.length > 1 ? "s" : ""}) baseUrl=${base}`);
1562
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: gid, keys: keys.length, baseUrl: base });
1563
+ } catch (err) {
1564
+ console.log(`provider ${gid} failed: ${err?.message || err}`);
1565
+ appendEvent({ ts: Date.now(), type: "provider-error", provider: gid, error: String(err?.message || err) });
1566
+ }
1567
+ }
1568
+ // workbuddy via env-only (no providerConfigs entry but MSLXDFF_WORKBUDDY_KEY set)
1569
+ if (!genericConfigs["workbuddy"]) {
1570
+ const wbKeys = loadProviderKeys("workbuddy");
1571
+ if (wbKeys.length) {
1572
+ try {
1573
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1574
+ const wbAuths = loadProviderAuths("workbuddy");
1575
+ providers.push(createWorkbuddyProvider({ apiKeys: wbKeys, auths: wbAuths }));
1576
+ console.log(`provider enabled: workbuddy (${wbKeys.length} key${wbKeys.length > 1 ? "s" : ""}) baseUrl=https://copilot.tencent.com (env)`);
1577
+ appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "workbuddy", keys: wbKeys.length, baseUrl: "https://copilot.tencent.com" });
1578
+ } catch (err) {
1579
+ console.log(`provider workbuddy failed: ${err?.message || err}`);
1580
+ }
1581
+ }
1582
+ }
1583
+ const { createProviderDispatcher } = await import("../src/providers/dispatcher.js");
1584
+ upstream = createProviderDispatcher(providers);
1585
+ appendEvent({ ts: Date.now(), type: "providers", providers: providers.map((p) => p.id) });
949
1586
  }
950
- if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
951
- const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
1587
+ const opencodeProvider = providers.find((p) => p.id === "opencode");
952
1588
  const models = createModelsService({
1589
+ providers: providers.length > 1 ? providers : undefined,
953
1590
  baseUrl,
954
- headers: upstream.headers,
1591
+ headers: upstream.headers || opencodeProvider?.upstream?.headers,
955
1592
  refreshMs: refreshIntervalMs(),
956
1593
  cacheFile: join(logDir(), "models.json"),
957
1594
  });
@@ -1357,6 +1994,12 @@ Usage:
1357
1994
  mslxdff -showtoken print the current auth token
1358
1995
  mslxdff -refresh-token rotate the auth token (prints the new one)
1359
1996
  mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
1997
+ 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)
1998
+ mslxdff -provider add workbuddy https://copilot.tencent.com <key> [allow...] add WorkBuddy专用供应商(workbuddy/hy3 前缀路由,auths 与 keys 一一对应)
1999
+ mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url] configure provider API keys/URL (multiple keys = rotating, set-url for generic)
2000
+ mslxdff -provider <id> allowlist [list|set|add|remove|clear] manage allowed models (empty=allow all, non-empty=only listed)
2001
+ mslxdff -workbuddy checkin daily 100 credits(双域幂等,已签 code 10001 视为成功)
2002
+ mslxdff -providers list list all configured upstream providers (opencode, openrouter, generic, workbuddy)
1360
2003
  mslxdff -creategroup <name> create a group on this node (the group name is the password)
1361
2004
  mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
1362
2005
  mslxdff -group sync pull the freshest member list for all joined groups
@@ -1365,6 +2008,7 @@ Usage:
1365
2008
  mslxdff -group remove <seq> leader only: kick a member by its list sequence number
1366
2009
  mslxdff -leavegroup leave every joined group as a member (leaders: use -delgroup)
1367
2010
  mslxdff -delgroup <name> disband a group this node leads (deletes it and its members)
2011
+ mslxdff -chat ["prompt"] chat REPL(mimo-v2.5-free 优先/big-pickle 兜底,自然语言转命令,模糊匹配由模型完成,历史持久化,超长自动压缩,仅拦 -uninstall,daemon 重启不影响)
1368
2012
  mslxdff -resetban [ip] clear join-failure bans (all, or one ip)
1369
2013
  mslxdff -help show this help
1370
2014
 
@@ -1374,6 +2018,7 @@ Environment:
1374
2018
  MSLXDFF_DAEMON_DIR daemon pid/log/models dir
1375
2019
  UPSTREAM_BASE_URL upstream base (default https://opencode.ai)
1376
2020
  UPSTREAM_AUTH_TOKEN upstream bearer value (default "public")
2021
+ MSLXDFF_OPENROUTER_KEY openrouter provider API keys(s) (single env value; multiple use "mslxdff -provider openrouter k1 k2 ..." to persist)
1377
2022
  UPSTREAM_CONNECT_TIMEOUT_MS upstream connect timeout (default 30000)
1378
2023
  MODELS_REFRESH_MS model-list background refresh interval (default 7200000)
1379
2024
  MSLXDFF_MODEL_COOLDOWN_MS fallback cooldown after a model error (default 60000)
@@ -1516,9 +2161,9 @@ async function printStatus() {
1516
2161
 
1517
2162
  function fmtStatus(id, statuses) {
1518
2163
  const e = statuses[id];
1519
- if (typeof e === "number") return `error ${fmtTs(new Date(e).toISOString())}`;
2164
+ if (typeof e === "number") return `error ${fmtTs(e)}`;
1520
2165
  if (!e?.status || e.status === "normal") return "";
1521
- const when = e.at ? ` ${fmtTs(new Date(e.at).toISOString())}` : "";
2166
+ const when = e.at ? ` ${fmtTs(e.at)}` : "";
1522
2167
  const code = e.code ? ` HTTP ${e.code}` : "";
1523
2168
  return `${e.status}${when}${code}`;
1524
2169
  }
@@ -1529,7 +2174,7 @@ function fmtDur(ms) {
1529
2174
  }
1530
2175
 
1531
2176
  function fmtEvent(e) {
1532
- const t = e?.ts ? new Date(e.ts).toISOString().slice(11, 19) : "--:--:--";
2177
+ const t = e?.ts ? fmtShanghaiHMS(e.ts) : "--:--:--";
1533
2178
  const head = `[${t}]`;
1534
2179
  const m = (x) => x || "-";
1535
2180
  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 +2223,7 @@ function fmtEvent(e) {
1578
2223
  case "relay-forward":
1579
2224
  return `${head} relay fwd ${e.target || e.peer || "?"} via leader model=${m(e.model)} hops=${e.hops || 0}`;
1580
2225
  case "relay-heartbeat":
1581
- return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? new Date(e.lastSeen).toISOString().slice(11,19) : "?"}`;
2226
+ return `${head} relay hb ${e.member || "?"} ip=${e.ip || "?"} lastSeen=${e.lastSeen ? fmtShanghaiHMS(e.lastSeen) : "?"}`;
1582
2227
  case "client-abort":
1583
2228
  return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
1584
2229
  case "result":
@@ -1617,12 +2262,7 @@ function fmtEvent(e) {
1617
2262
  }
1618
2263
 
1619
2264
  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
- }
2265
+ return fmtShanghai(iso);
1626
2266
  }
1627
2267
 
1628
2268
  function npmCmd() {