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/src/state.js CHANGED
@@ -41,15 +41,31 @@ function loadFromDisk(file) {
41
41
 
42
42
  function readState(file) {
43
43
  const e = getEntry(file);
44
- // 脏数据直接从内存拿,不碰磁盘
45
- if (e.data !== null && e.dirty) return e.data;
46
- // 已有干净缓存且文件未变,直接命中
44
+ // 即使脏数据,也要检查 mtime 是否被外部(CLI)改过;若改过则合并,避免 CLI 的 providerConfigs 白名单等被 daemon 的 modelErrors 刷盘覆盖
47
45
  if (e.data !== null) {
48
46
  try {
49
47
  const st = statSync(file);
50
48
  if (st.mtimeMs === e.mtimeMs) return e.data;
49
+ // mtime 变了,说明外部改过文件
50
+ if (e.dirty) {
51
+ const disk = loadFromDisk(file);
52
+ const diskObj = typeof disk === "object" && disk !== null ? disk : {};
53
+ // 合并:disk 为新基准,e.data 的热数据(modelErrors/modelLatencies/peerStats 等)覆盖回去,但 providerConfigs 等配置以 disk 为准
54
+ const merged = { ...diskObj, ...e.data };
55
+ // 配置类字段以 disk 为准(CLI 改的白名单、keys、port、token 等)
56
+ if (diskObj.providerConfigs !== undefined) merged.providerConfigs = diskObj.providerConfigs;
57
+ if (diskObj.providerKeys !== undefined) merged.providerKeys = diskObj.providerKeys;
58
+ if (diskObj.providerShareKeys !== undefined) merged.providerShareKeys = diskObj.providerShareKeys;
59
+ if (diskObj.port !== undefined) merged.port = diskObj.port;
60
+ if (diskObj.token !== undefined) merged.token = diskObj.token;
61
+ if (diskObj.preferredModel !== undefined) merged.preferredModel = diskObj.preferredModel;
62
+ if (diskObj.modelPicks !== undefined) merged.modelPicks = diskObj.modelPicks;
63
+ e.data = merged;
64
+ e.mtimeMs = st.mtimeMs;
65
+ // 保持 dirty,让热数据下次刷盘时带上合并后的配置
66
+ return e.data;
67
+ }
51
68
  } catch {
52
- // 文件不存在等,沿用内存
53
69
  if (e.data) return e.data;
54
70
  }
55
71
  }
@@ -201,6 +217,285 @@ export function loadPreferredModel({ file = defaultStateFile() } = {}) {
201
217
  return typeof v === "string" && v.trim() ? v.trim() : null;
202
218
  }
203
219
 
220
+ // 供应商 API key:state 存 `providerKeys: { openrouter: ["sk-1", "sk-2"] }`
221
+ // 兼容旧版单字符串 `openrouter: "sk-1"`(读到自动转数组)。
222
+ // env `MSLXDFF_<ID>_KEY`(大写)优先,state 兜底;key 为空视为未配置
223
+ export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
224
+ export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
225
+
226
+ export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
227
+ const env = (process.env[providerKeyEnv(id)] || "").trim();
228
+ if (env) return [env];
229
+ // 通用供应商的 keys 优先从 providerConfigs 读取
230
+ const configs = readState(file).providerConfigs;
231
+ const cfg = configs && typeof configs === "object" ? configs[id] : undefined;
232
+ if (cfg && typeof cfg === "object" && Array.isArray(cfg.keys)) {
233
+ const list = [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))];
234
+ if (list.length) return list;
235
+ }
236
+ const keys = readState(file).providerKeys;
237
+ const v = keys && typeof keys === "object" ? keys[id] : undefined;
238
+ if (typeof v === "string") return v.trim() ? [v.trim()] : [];
239
+ if (Array.isArray(v)) return [...new Set(v.filter((x) => typeof x === "string" && x.trim().length))];
240
+ return [];
241
+ }
242
+
243
+ export function loadProviderKey(id, opts = {}) {
244
+ return loadProviderKeys(id, opts)[0] || "";
245
+ }
246
+
247
+ export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
248
+ const keys = { ...(readState(file).providerKeys || {}) };
249
+ const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
250
+ if (clean.length) keys[id] = clean;
251
+ else delete keys[id];
252
+ writeStateImmediate(file, { providerKeys: keys });
253
+ return clean;
254
+ }
255
+
256
+ export function saveProviderKey(id, key, opts = {}) {
257
+ const list = [...loadProviderKeys(id, opts)];
258
+ const clean = String(key || "").trim();
259
+ if (clean && !list.includes(clean)) list.push(clean);
260
+ return saveProviderKeys(id, clean ? list : [], opts);
261
+ }
262
+
263
+ export function addProviderKey(id, key, opts = {}) {
264
+ return saveProviderKey(id, key, opts);
265
+ }
266
+
267
+ export function removeProviderKey(id, key, opts = {}) {
268
+ return removeProviderKeys(id, [key], opts);
269
+ }
270
+
271
+ export function removeProviderKeys(id, targets = [], opts = {}) {
272
+ const set = new Set((Array.isArray(targets) ? targets : [targets]).map((k) => String(k || "").trim()).filter(Boolean));
273
+ const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
274
+ return saveProviderKeys(id, list, opts);
275
+ }
276
+
277
+ // 供应商 key 瞬时共享开关(ADR-0008):默认 false;true = 本节点在 outgoing 转发时附带
278
+ // 该供应商 key 给组员(瞬时借用,不落盘)。env `MSLXDFF_<ID>_SHARE_KEYS` 可覆盖 state。
279
+ export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
280
+
281
+ export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
282
+ const env = process.env[providerShareEnv(id)] || "";
283
+ if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
284
+ const map = readState(file).providerShareKeys;
285
+ return !!(map && typeof map === "object" && map[id]);
286
+ }
287
+
288
+ export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
289
+ const map = { ...(readState(file).providerShareKeys || {}) };
290
+ if (on) map[id] = true;
291
+ else delete map[id];
292
+ writeStateImmediate(file, { providerShareKeys: map });
293
+ return !!on;
294
+ }
295
+
296
+ // ---- 通用 OpenAI 兼容供应商配置:providerConfigs: { [id]: { baseUrl, keys, auths, allowedModels } } ----
297
+ export const WORKBUDDY_DEFAULT_BASE_URL = "https://copilot.tencent.com";
298
+ function normalizeBaseUrl(url) {
299
+ const s = String(url || "").trim();
300
+ if (!s) return "";
301
+ return s.replace(/\/+$/, "");
302
+ }
303
+ function normalizeAuths(list) {
304
+ if (!Array.isArray(list)) return [];
305
+ const out = [];
306
+ for (const a of list) {
307
+ if (!a || typeof a !== "object") continue;
308
+ const uid = String(a.uid || a.userId || "").trim();
309
+ if (!uid) continue;
310
+ out.push({
311
+ uid,
312
+ domain: String(a.domain || "www.codebuddy.cn").trim() || "www.codebuddy.cn",
313
+ enterpriseId: String(a.enterpriseId || a.enterprise_id || "").trim(),
314
+ refreshToken: String(a.refreshToken || a.refresh_token || "").trim(),
315
+ });
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function normalizeAllowedModel(model, providerId) {
321
+ let s = String(model || "").trim();
322
+ if (!s) return "";
323
+ // 支持带前缀的输入:bai/glm-5.3-flash -> glm-5.3-flash(仅当 provider 匹配时剥前缀)
324
+ const idx = s.indexOf("/");
325
+ if (idx > 0) {
326
+ const head = s.slice(0, idx).toLowerCase();
327
+ if (head === String(providerId || "").toLowerCase()) s = s.slice(idx + 1);
328
+ }
329
+ return s;
330
+ }
331
+
332
+ export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
333
+ const v = readState(file).providerConfigs;
334
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
335
+ return {};
336
+ }
337
+
338
+ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
339
+ const envUrl = (process.env[providerBaseUrlEnv(id)] || "").trim();
340
+ const envKeys = loadProviderKeys(id, { file });
341
+ // env 覆盖时以 env 为准
342
+ if (envUrl || (process.env[providerKeyEnv(id)] || "").trim()) {
343
+ const baseUrl = envUrl || loadProviderConfigs({ file })[id]?.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "");
344
+ const cfg = loadProviderConfigs({ file })[id];
345
+ const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
346
+ const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
347
+ if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels };
348
+ return null;
349
+ }
350
+ const configs = loadProviderConfigs({ file });
351
+ const cfg = configs[id];
352
+ if (cfg && typeof cfg === "object") {
353
+ return {
354
+ baseUrl: normalizeBaseUrl(cfg.baseUrl || (String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "")),
355
+ keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
356
+ auths: normalizeAuths(cfg.auths),
357
+ allowedModels: Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [],
358
+ };
359
+ }
360
+ // 兼容旧 providerKeys 形态:有 key 但无 configs 时视为通用供应商(baseUrl 为空,需后补)
361
+ const keys = loadProviderKeys(id, { file });
362
+ if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [] };
363
+ return null;
364
+ }
365
+
366
+ export function loadProviderBaseUrl(id, opts = {}) {
367
+ const env = (process.env[providerBaseUrlEnv(id)] || "").trim();
368
+ if (env) return normalizeBaseUrl(env);
369
+ const cfg = loadProviderConfigs(opts)[id];
370
+ if (cfg && typeof cfg.baseUrl === "string" && normalizeBaseUrl(cfg.baseUrl)) return normalizeBaseUrl(cfg.baseUrl);
371
+ if (String(id || "").toLowerCase() === "workbuddy") return WORKBUDDY_DEFAULT_BASE_URL;
372
+ return "";
373
+ }
374
+
375
+ export function loadProviderAuths(id, { file = defaultStateFile() } = {}) {
376
+ const cfg = loadProviderConfigs({ file })[id];
377
+ if (cfg && Array.isArray(cfg.auths)) return normalizeAuths(cfg.auths);
378
+ return [];
379
+ }
380
+ export function saveProviderAuths(id, list, { file = defaultStateFile() } = {}) {
381
+ const clean = normalizeAuths(list);
382
+ const configs = { ...loadProviderConfigs({ file }) };
383
+ const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
384
+ const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
385
+ const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
386
+ const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
387
+ if (!baseUrl && !keys.length && !clean.length && !allowedModels.length) {
388
+ delete configs[id];
389
+ } else {
390
+ configs[id] = { baseUrl, keys };
391
+ if (clean.length) configs[id].auths = clean;
392
+ if (allowedModels.length) configs[id].allowedModels = allowedModels;
393
+ }
394
+ writeStateImmediate(file, { providerConfigs: configs });
395
+ return clean;
396
+ }
397
+
398
+ export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } = {}) {
399
+ const clean = normalizeBaseUrl(baseUrl);
400
+ const configs = { ...loadProviderConfigs({ file }) };
401
+ const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
402
+ const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
403
+ const auths = normalizeAuths(cur.auths);
404
+ const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
405
+ if (!clean && !keys.length && !auths.length && !allowedModels.length) {
406
+ delete configs[id];
407
+ } else {
408
+ configs[id] = { baseUrl: clean, keys };
409
+ if (auths.length) configs[id].auths = auths;
410
+ if (allowedModels.length) configs[id].allowedModels = allowedModels;
411
+ }
412
+ writeStateImmediate(file, { providerConfigs: configs });
413
+ return clean;
414
+ }
415
+
416
+ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels }, { file = defaultStateFile() } = {}) {
417
+ const cleanUrl = normalizeBaseUrl(baseUrl);
418
+ const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
419
+ const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
420
+ const cleanAllowed = [...new Set((Array.isArray(allowedModels) ? allowedModels : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
421
+ const configs = { ...loadProviderConfigs({ file }) };
422
+ const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
423
+ // 保留已有的 allowedModels / auths 若本次未传入
424
+ const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
425
+ const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
426
+ if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length) {
427
+ delete configs[id];
428
+ } else {
429
+ configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
430
+ if (finalAuths.length) configs[id].auths = finalAuths;
431
+ if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
432
+ }
433
+ // 同步清理旧 providerKeys 中同 id 的残留,避免双写
434
+ const oldKeys = readState(file).providerKeys;
435
+ if (oldKeys && typeof oldKeys === "object" && oldKeys[id] !== undefined) {
436
+ const nextKeys = { ...oldKeys };
437
+ delete nextKeys[id];
438
+ writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
439
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
440
+ }
441
+ writeStateImmediate(file, { providerConfigs: configs });
442
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
443
+ }
444
+
445
+ // ---- 供应商模型白名单:providerConfigs.<id>.allowedModels(空 = 不限) ----
446
+ export function loadProviderAllowedModels(id, { file = defaultStateFile() } = {}) {
447
+ const cfg = loadProviderConfig(id, { file });
448
+ if (cfg && Array.isArray(cfg.allowedModels)) return [...cfg.allowedModels];
449
+ const raw = readState(file).providerConfigs?.[id];
450
+ if (raw && Array.isArray(raw.allowedModels)) return [...new Set(raw.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
451
+ return [];
452
+ }
453
+
454
+ export function saveProviderAllowedModels(id, list, { file = defaultStateFile() } = {}) {
455
+ const clean = [...new Set((Array.isArray(list) ? list : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
456
+ const configs = { ...loadProviderConfigs({ file }) };
457
+ const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
458
+ const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
459
+ const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
460
+ const auths = normalizeAuths(cur.auths);
461
+ if (!baseUrl && !keys.length && !auths.length && !clean.length) {
462
+ delete configs[id];
463
+ } else {
464
+ configs[id] = { baseUrl, keys };
465
+ if (auths.length) configs[id].auths = auths;
466
+ if (clean.length) configs[id].allowedModels = clean;
467
+ }
468
+ writeStateImmediate(file, { providerConfigs: configs });
469
+ return clean;
470
+ }
471
+
472
+ export function addProviderAllowedModel(id, model, opts = {}) {
473
+ const cur = loadProviderAllowedModels(id, opts);
474
+ const norm = normalizeAllowedModel(model, id);
475
+ if (!norm || cur.includes(norm)) return cur;
476
+ return saveProviderAllowedModels(id, [...cur, norm], opts);
477
+ }
478
+
479
+ export function removeProviderAllowedModel(id, model, opts = {}) {
480
+ return removeProviderAllowedModels(id, [model], opts);
481
+ }
482
+
483
+ export function removeProviderAllowedModels(id, targets = [], opts = {}) {
484
+ const set = new Set((Array.isArray(targets) ? targets : [targets]).map((m) => normalizeAllowedModel(m, id)).filter(Boolean));
485
+ const cur = loadProviderAllowedModels(id, opts);
486
+ const next = cur.filter((m) => !set.has(m));
487
+ return saveProviderAllowedModels(id, next, opts);
488
+ }
489
+
490
+ export function isModelAllowed(id, rawModel, { file = defaultStateFile() } = {}) {
491
+ const raw = String(rawModel || "").trim();
492
+ if (!raw) return true;
493
+ const allowed = loadProviderAllowedModels(id, { file });
494
+ if (!allowed.length) return true;
495
+ const norm = normalizeAllowedModel(raw, id);
496
+ return allowed.includes(norm);
497
+ }
498
+
204
499
  // 常用模型勾选集(auto 候选池白名单):空数组 = 不启用筛选(全量 auto)
205
500
  export function loadModelPicks({ file = defaultStateFile() } = {}) {
206
501
  const picks = readState(file).modelPicks;
package/src/time.js ADDED
@@ -0,0 +1,73 @@
1
+ const TZ = "Asia/Shanghai";
2
+
3
+ function shanghaiParts(d) {
4
+ const date = d instanceof Date ? d : new Date(d);
5
+ const fmt = new Intl.DateTimeFormat("en-GB", {
6
+ timeZone: TZ,
7
+ year: "numeric",
8
+ month: "2-digit",
9
+ day: "2-digit",
10
+ hour: "2-digit",
11
+ minute: "2-digit",
12
+ second: "2-digit",
13
+ hour12: false,
14
+ });
15
+ const parts = fmt.formatToParts(date);
16
+ const m = {};
17
+ for (const p of parts) m[p.type] = p.value;
18
+ return m; // {year, month, day, hour, minute, second}
19
+ }
20
+
21
+ // "MM-DD HH:mm:ss" e.g. "08-27 15:07:14"
22
+ export function fmtShanghai(isoOrTs) {
23
+ if (isoOrTs == null) return "-";
24
+ try {
25
+ const p = shanghaiParts(isoOrTs);
26
+ return `${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
27
+ } catch {
28
+ return "-";
29
+ }
30
+ }
31
+
32
+ // "YYYY-MM-DD HH:mm" e.g. "2026-08-27 15:07"
33
+ export function fmtShanghaiYMDHM(isoOrTs) {
34
+ if (isoOrTs == null) return "-";
35
+ try {
36
+ const p = shanghaiParts(isoOrTs);
37
+ return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
38
+ } catch {
39
+ return "-";
40
+ }
41
+ }
42
+
43
+ // "YYYY-MM-DD HH:mm:ss" e.g. "2026-08-27 15:07:14"
44
+ export function fmtShanghaiYMDHMS(isoOrTs) {
45
+ if (isoOrTs == null) return "-";
46
+ try {
47
+ const p = shanghaiParts(isoOrTs);
48
+ return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
49
+ } catch {
50
+ return "-";
51
+ }
52
+ }
53
+
54
+ // "HH:mm:ss" e.g. "15:07:14"
55
+ export function fmtShanghaiHMS(isoOrTs) {
56
+ if (isoOrTs == null) return "--:--:--";
57
+ try {
58
+ const p = shanghaiParts(isoOrTs);
59
+ return `${p.hour}:${p.minute}:${p.second}`;
60
+ } catch {
61
+ return "--:--:--";
62
+ }
63
+ }
64
+
65
+ // 当前上海时间 "YYYY-MM-DD HH:mm" 用于 prompt
66
+ export function nowShanghaiYMDHM() {
67
+ return fmtShanghaiYMDHM(new Date());
68
+ }
69
+
70
+ // 兼容:传入 Date 或 ts 或 ISO,都返回 "MM-DD HH:mm:ss"
71
+ export function fmtTsShanghai(iso) {
72
+ return fmtShanghai(iso);
73
+ }
package/src/upstream.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import os from "node:os";
6
6
  import { isFreeModel } from "./models.js";
7
7
  import { defaultStateFile } from "./state.js";
8
+ import { fmtShanghaiYMDHMS } from "./time.js";
8
9
 
9
10
  let UndiciAgent = null;
10
11
  let UndiciFetch = null;
@@ -45,6 +46,7 @@ export function createUpstreamClient({
45
46
  },
46
47
  fetchImpl,
47
48
  hooks,
49
+ keepAlive = true,
48
50
  } = {}) {
49
51
  // 使用 undici 的 fetch 与 Agent 配对,避免 global fetch 与 npm undici Agent 不兼容
50
52
  if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
@@ -91,6 +93,74 @@ export function createUpstreamClient({
91
93
  return true;
92
94
  }
93
95
 
96
+ function isResponsesModel(model) {
97
+ const m = String(model || "").toLowerCase();
98
+ return m.startsWith("muse-spark");
99
+ }
100
+
101
+ function chatToResponsesBody(chatBody) {
102
+ const msgs = Array.isArray(chatBody?.messages) ? chatBody.messages : [];
103
+ const system = msgs.filter((m) => m.role === "system").map((m) => String(m.content || "")).join("\n");
104
+ const nonSystem = msgs.filter((m) => m.role !== "system");
105
+ // 取最后一条 user 内容作为 input,其余拼到 input 前(保留上下文)
106
+ const inputParts = nonSystem.map((m) => {
107
+ const c = m.content;
108
+ if (typeof c === "string") return `${m.role}: ${c}`;
109
+ if (Array.isArray(c)) return `${m.role}: ${c.map((x) => x.text || "").join("")}`;
110
+ return `${m.role}: ${String(c || "")}`;
111
+ });
112
+ const input = inputParts.join("\n\n") || "hi";
113
+ const out = {
114
+ model: chatBody.model,
115
+ input,
116
+ stream: false, // muse-spark 的 chat 流式经 responses 的 SSE 格式不同,先以非流式 200 保证可用
117
+ };
118
+ if (system) out.instructions = system;
119
+ if (chatBody.tools) out.tools = chatBody.tools;
120
+ if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
121
+ if (chatBody.temperature != null) out.temperature = chatBody.temperature;
122
+ if (chatBody.max_tokens != null) out.max_output_tokens = chatBody.max_tokens;
123
+ return out;
124
+ }
125
+
126
+ function responsesToChatJson(respJson) {
127
+ // responses: {id, model, output:[{type:reasoning},{type:message, content:[{type:output_text,text}]}]}
128
+ let text = "";
129
+ for (const item of respJson.output || []) {
130
+ if (item.type === "message" && item.role === "assistant") {
131
+ for (const c of item.content || []) {
132
+ if (c.type === "output_text") text += c.text || "";
133
+ else if (c.type === "text") text += c.text || "";
134
+ }
135
+ }
136
+ }
137
+ if (!text) {
138
+ // 兜底:取 output_text 的第一个
139
+ for (const item of respJson.output || []) {
140
+ if (item.type === "message") {
141
+ const t = item.content?.[0]?.text;
142
+ if (t) { text = t; break; }
143
+ }
144
+ }
145
+ }
146
+ if (!text) text = "";
147
+ const chatJson = {
148
+ id: respJson.id || `resp_${Date.now()}`,
149
+ object: "chat.completion",
150
+ created: Math.floor((respJson.created_at || Date.now() / 1000)),
151
+ model: respJson.model,
152
+ choices: [
153
+ {
154
+ index: 0,
155
+ finish_reason: respJson.status === "completed" ? "stop" : "length",
156
+ message: { role: "assistant", content: text },
157
+ },
158
+ ],
159
+ usage: respJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
160
+ };
161
+ return chatJson;
162
+ }
163
+
94
164
  function freeAnonLogFile() {
95
165
  // 按用户要求:放在当前项目目录,方便一眼看到;可用 env 覆盖
96
166
  return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
@@ -98,7 +168,7 @@ export function createUpstreamClient({
98
168
 
99
169
  function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
100
170
  const file = freeAnonLogFile();
101
- const line = `${new Date().toISOString()} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
171
+ const line = `${fmtShanghaiYMDHMS(new Date())} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
102
172
  try {
103
173
  mkdirSync(dirname(file), { recursive: true });
104
174
  } catch {}
@@ -118,7 +188,7 @@ export function createUpstreamClient({
118
188
  const keepAliveConnections = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_CONNECTIONS", 20);
119
189
  let dispatcher = null;
120
190
  let agent = null;
121
- if (UndiciAgent) {
191
+ if (keepAlive && UndiciAgent) {
122
192
  try {
123
193
  agent = new UndiciAgent({
124
194
  keepAliveTimeout,
@@ -134,13 +204,15 @@ export function createUpstreamClient({
134
204
  }
135
205
 
136
206
  async function chat(body) {
137
- const url = `${baseUrl}/zen/v1/chat/completions`;
207
+ const isResp = isResponsesModel(body?.model);
208
+ const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
209
+ const reqBody = isResp ? chatToResponsesBody(body) : body;
138
210
  const t0 = performance.now();
139
211
  const attempts = [];
140
212
  let waitMs = 0;
141
213
  for (let attempt = 0; ; attempt++) {
142
214
  const t = performance.now();
143
- const result = await attemptOnce(url, body);
215
+ const result = await attemptOnce(url, reqBody);
144
216
  attempts.push({
145
217
  attempt,
146
218
  type: result instanceof Error ? "network" : `http${result.status}`,
@@ -177,7 +249,7 @@ export function createUpstreamClient({
177
249
  await sleep(anonDelay);
178
250
  waitMs += anonDelay;
179
251
  const t2 = performance.now();
180
- anonResult = await attemptOnce(url, body, { anonymous: true });
252
+ anonResult = await attemptOnce(url, reqBody, { anonymous: true });
181
253
  attempts.push({
182
254
  attempt: `anon-${i}`,
183
255
  type: anonResult instanceof Error ? "network" : `http${anonResult.status}`,
@@ -185,7 +257,23 @@ export function createUpstreamClient({
185
257
  });
186
258
  if (anonResult instanceof Error) continue;
187
259
  if (anonResult.status !== 429) {
188
- anonResult._t = {
260
+ // responses 模型的 anon 命中也需转回 chat 形状
261
+ let outAnon = anonResult;
262
+ if (isResp && anonResult.ok) {
263
+ try {
264
+ const txt = await anonResult.text();
265
+ const j = JSON.parse(txt);
266
+ if (j && Array.isArray(j.output)) {
267
+ const chatJson = responsesToChatJson(j);
268
+ const newHeaders = new Headers(anonResult.headers);
269
+ newHeaders.set("content-type", "application/json");
270
+ outAnon = new Response(JSON.stringify(chatJson), { status: anonResult.status, headers: newHeaders });
271
+ } else {
272
+ outAnon = new Response(txt, { status: anonResult.status, headers: anonResult.headers });
273
+ }
274
+ } catch { outAnon = anonResult; }
275
+ }
276
+ outAnon._t = {
189
277
  attempts,
190
278
  waitMs,
191
279
  totalMs: Math.round(performance.now() - t0),
@@ -195,7 +283,7 @@ export function createUpstreamClient({
195
283
  hit = true;
196
284
  hitAt = i + 1;
197
285
  consecutiveHits += 1;
198
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: anonResult._t.totalMs });
286
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
199
287
  // 额外在 txt 里强调连续命中
200
288
  try {
201
289
  if (consecutiveHits >= 2) {
@@ -204,7 +292,7 @@ export function createUpstreamClient({
204
292
  }
205
293
  } catch {}
206
294
  if (process.env.MSLXDFF_DEBUG === "1") try { console.log(`[free-anon] ${body?.model} public 429 -> anon ${anonResult.status} after ${i + 1} try HIT`); } catch {}
207
- return anonResult;
295
+ return outAnon;
208
296
  }
209
297
  }
210
298
  // 3 次 anon 仍 429
@@ -222,6 +310,36 @@ export function createUpstreamClient({
222
310
  return anonResult;
223
311
  }
224
312
  }
313
+ // Muse Spark 走 /responses,需把 responses 的 JSON 转回 chat.completions 形状,保持上层无感
314
+ if (isResp && result.ok) {
315
+ try {
316
+ const txt = await result.text();
317
+ const j = JSON.parse(txt);
318
+ // responses 成功态:{output:[...]};chat 态:{choices:[...]}。非 responses 形状则原样回
319
+ if (j && Array.isArray(j.output)) {
320
+ const chatJson = responsesToChatJson(j);
321
+ const newBody = JSON.stringify(chatJson);
322
+ const newHeaders = new Headers(result.headers);
323
+ newHeaders.set("content-type", "application/json");
324
+ const transformed = new Response(newBody, { status: result.status, headers: newHeaders });
325
+ transformed._t = {
326
+ attempts,
327
+ waitMs,
328
+ totalMs: Math.round(performance.now() - t0),
329
+ };
330
+ // 保留原始 timing 供日志
331
+ if (result._t) transformed._t = { ...transformed._t, ...result._t };
332
+ return transformed;
333
+ }
334
+ // 不是 responses 形状,回退:用原文本重建可读 Response
335
+ const fallback = new Response(txt, { status: result.status, headers: result.headers });
336
+ fallback._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
337
+ return fallback;
338
+ } catch {
339
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
340
+ return result;
341
+ }
342
+ }
225
343
  result._t = {
226
344
  attempts,
227
345
  waitMs,
@@ -1,14 +0,0 @@
1
- # ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
2
-
3
- The Zen upstream's thinking-mode models (deepseek-family at minimum) return
4
- `400 "The reasoning_content in the thinking mode must be passed back"` when a
5
- multi-turn request echoes an assistant message without its `reasoning_content`.
6
- Clients speaking plain OpenAI format never send that field, so the proxy writes
7
- a `" "` placeholder into assistant messages before forwarding. Scope is `all`
8
- for deepseek-family models and `tool_calls` for kimi-family models; messages
9
- that already carry non-empty `reasoning_content` are left untouched.
10
-
11
- The alternative — telling clients to manage `reasoning_content` themselves —
12
- would break standard OpenAI-compatible clients, so the proxy eats this
13
- compatibility cost instead. Matches `/root/9router` v0.5.45
14
- `open-sse/utils/reasoningContentInjector.js`.
@@ -1,12 +0,0 @@
1
- # ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
2
-
3
- The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
4
- pollute clients with paid models this proxy can't serve for free. `/v1/models`
5
- therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
6
- `big-pickle`. The whitelist exists because `big-pickle` is a free model without
7
- the `-free` suffix, and a suffix-only filter would silently drop it.
8
-
9
- A plain `endsWith("-free")` filter was considered and rejected for exactly that
10
- reason. Matches `/root/9router` v0.5.45
11
- `src/app/api/providers/suggested-models/filters.js`
12
- (`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
@@ -1,10 +0,0 @@
1
- # ADR-0003: Zero-state, no-DB, no-account local proxy
2
-
3
- Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
4
- no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
5
- stand.
6
-
7
- By design this proxy holds no database, no token store, no account rotation,
8
- and no cloud sync. A single static bearer token is the only credential, kept
9
- in a 0600 state file (see ADR-0004); everything else is stateless per-process
10
- memory at most.
@@ -1,18 +0,0 @@
1
- # ADR-0004: Single static bearer token, persisted in a 0600 state file
2
-
3
- The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
4
- isn't an open relay. There is no account system: the token is a random
5
- `crypto` 32-byte value (hex), generated once on first run, persisted to a
6
- state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
7
- via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
8
- `mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
9
- new token, and exits (does not start the server).
10
-
11
- Auth is enforced with a constant-time string compare on
12
- `Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
13
- `/health` stays public (no token). Tokens never appear in logs.
14
-
15
- Alternatives rejected: a fixed default token (same key on every install),
16
- per-user accounts (needs a DB — that's the 9Router provisioning surface we
17
- rejected in ADR-0003), and unauthenticated local-only binding (fragile;
18
- a proxy relay deserves an explicit secret even on localhost).