mslxdff 0.1.63 → 0.1.64
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.
- package/bin/mslxdff.js +234 -35
- package/package.json +1 -1
- package/src/auto.js +13 -0
- package/src/chat/config.js +2 -1
- package/src/chat/repl.js +12 -49
- package/src/chat/stats.js +1 -1
- package/src/chat/upstream.js +183 -5
package/bin/mslxdff.js
CHANGED
|
@@ -295,11 +295,54 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
295
295
|
// provider filter: bare ids are opencode, prefixed are <provider>/...
|
|
296
296
|
if (modelListProvider) {
|
|
297
297
|
const prov = String(modelListProvider).toLowerCase();
|
|
298
|
-
|
|
298
|
+
const filtered = ids.filter((id) => {
|
|
299
299
|
const slash = String(id).indexOf("/");
|
|
300
300
|
const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
|
|
301
301
|
return p === prov;
|
|
302
302
|
});
|
|
303
|
+
// 非 opencode 供应商的模型不在 opencode 缓存中,改从 allowlist 展示(原名+别名)
|
|
304
|
+
if (prov !== "opencode" && filtered.length === 0) {
|
|
305
|
+
try {
|
|
306
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
307
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
308
|
+
try { loadModelAliases(); } catch {}
|
|
309
|
+
const allowed = loadProviderAllowedModels(prov);
|
|
310
|
+
const allowAny = loadProviderAllowAnyModels(prov);
|
|
311
|
+
const baseUrl = loadProviderBaseUrl(prov);
|
|
312
|
+
if (modelListJson) {
|
|
313
|
+
const data = allowed.length
|
|
314
|
+
? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
|
|
315
|
+
: [];
|
|
316
|
+
console.log(JSON.stringify({ object: "list", data }, null, 2));
|
|
317
|
+
process.exit(0);
|
|
318
|
+
}
|
|
319
|
+
if (!allowed.length) {
|
|
320
|
+
if (allowAny) {
|
|
321
|
+
console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
|
|
322
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
|
|
323
|
+
} else {
|
|
324
|
+
console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
|
|
325
|
+
console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
|
|
326
|
+
console.log(` live 查看: mslxdff -provider ${prov} models`);
|
|
327
|
+
}
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
331
|
+
console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
|
|
332
|
+
const pickedIds2 = loadModelPicks();
|
|
333
|
+
for (const raw of allowed) {
|
|
334
|
+
const canonical = `${prov}/${raw}`;
|
|
335
|
+
let alias = null;
|
|
336
|
+
try { alias = getAliasForModel(canonical); } catch {}
|
|
337
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
338
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
339
|
+
const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
|
|
340
|
+
console.log(` ${mark2} ${canonical}${aliasStr}`);
|
|
341
|
+
}
|
|
342
|
+
process.exit(0);
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
ids = filtered;
|
|
303
346
|
if (modelListJson) {
|
|
304
347
|
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
305
348
|
process.exit(0);
|
|
@@ -316,12 +359,42 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
316
359
|
console.log("no models available — try: mslxdff -model refresh");
|
|
317
360
|
process.exit(0);
|
|
318
361
|
}
|
|
319
|
-
// TTY
|
|
320
|
-
|
|
362
|
+
// TTY 交互式:`mslxdff -models`(无 list)走交互;` -model list` 默认列表,但交互入口统一为 -models
|
|
363
|
+
// 为支持“opencode + 其他供应商 allowlist 原名/别名”一起勾选,交互池 = opencode 免费池 + 各供应商 allowlist + 已勾选的遗留 picks
|
|
364
|
+
if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
321
365
|
const statuses = loadModelErrors();
|
|
322
366
|
const current = getPreferredModel();
|
|
323
367
|
const pickedIds = loadModelPicks();
|
|
324
|
-
|
|
368
|
+
// 基础:opencode 免费池
|
|
369
|
+
const combinedIds = [...ids];
|
|
370
|
+
const seen = new Set(combinedIds);
|
|
371
|
+
try {
|
|
372
|
+
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../src/state.js");
|
|
373
|
+
const configs = loadProviderConfigs();
|
|
374
|
+
for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
375
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
376
|
+
for (const raw of allowed) {
|
|
377
|
+
const canonical = `${pid}/${raw}`;
|
|
378
|
+
if (!seen.has(canonical)) {
|
|
379
|
+
seen.add(canonical);
|
|
380
|
+
combinedIds.push(canonical);
|
|
381
|
+
}
|
|
382
|
+
// 别名(dash 版)若已被 picks 选中,也确保可见(便于取消)
|
|
383
|
+
const aliasDash = String(canonical).replace(/\//g, "-");
|
|
384
|
+
if (aliasDash !== canonical && pickedIds.includes(aliasDash) && !seen.has(aliasDash)) {
|
|
385
|
+
// 不直接加入 alias 作为独立选项,保留 canonical 即可(canonical 与 alias 视为同一模型,勾选 canonical)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch {}
|
|
390
|
+
// 已勾选但不在上述池中的(例如 workbuddy/* 当 allowAny ON 时无 allowlist,或历史 picks),补齐以便取消
|
|
391
|
+
for (const pid of pickedIds) {
|
|
392
|
+
if (!seen.has(pid)) {
|
|
393
|
+
seen.add(pid);
|
|
394
|
+
combinedIds.push(pid);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const items = combinedIds.map((id) => {
|
|
325
398
|
const e = statuses[id];
|
|
326
399
|
return {
|
|
327
400
|
id,
|
|
@@ -342,38 +415,158 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
342
415
|
const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
343
416
|
const pickedIds = loadModelPicks();
|
|
344
417
|
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
418
|
+
// 分组展示:按供应商前缀分组,opencode 裸 id 归为 opencode
|
|
419
|
+
const groups = {};
|
|
420
|
+
for (const id of ids) {
|
|
421
|
+
const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
|
|
422
|
+
if (!groups[prov]) groups[prov] = [];
|
|
423
|
+
groups[prov].push(id);
|
|
424
|
+
}
|
|
425
|
+
const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
|
|
426
|
+
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
427
|
+
const ia = order.indexOf(a), ib = order.indexOf(b);
|
|
428
|
+
if (ia !== -1 || ib !== -1) {
|
|
429
|
+
if (ia === -1) return 1;
|
|
430
|
+
if (ib === -1) return -1;
|
|
431
|
+
return ia - ib;
|
|
355
432
|
}
|
|
356
|
-
|
|
433
|
+
return a.localeCompare(b);
|
|
434
|
+
});
|
|
435
|
+
if (modelListProvider) {
|
|
436
|
+
console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
|
|
437
|
+
// 加载别名映射,显示原始名+别名
|
|
438
|
+
let aliasMap = {};
|
|
357
439
|
try {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
440
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
441
|
+
loadModelAliases();
|
|
442
|
+
for (const id of ids) {
|
|
443
|
+
const alias = getAliasForModel(id);
|
|
444
|
+
if (alias) aliasMap[id] = alias;
|
|
445
|
+
else if (String(id).includes("/")) {
|
|
446
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
447
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
361
450
|
} catch {}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (aliasEntries.length) {
|
|
371
|
-
console.log(`\nlocal aliases (${aliasEntries.length}):`);
|
|
372
|
-
for (const [alias, canonical] of aliasEntries) {
|
|
373
|
-
console.log(` ${canonical} => ${alias}`);
|
|
451
|
+
for (const prov of sortedProvs) {
|
|
452
|
+
const list = groups[prov];
|
|
453
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
454
|
+
for (const id of list) {
|
|
455
|
+
const alias = aliasMap[id];
|
|
456
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
457
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
458
|
+
}
|
|
374
459
|
}
|
|
460
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
461
|
+
} else {
|
|
462
|
+
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
463
|
+
// 加载别名映射,显示原始名+别名
|
|
464
|
+
let aliasMap = {};
|
|
465
|
+
let fullAliases = {};
|
|
466
|
+
try {
|
|
467
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
468
|
+
loadModelAliases();
|
|
469
|
+
for (const id of ids) {
|
|
470
|
+
const alias = getAliasForModel(id);
|
|
471
|
+
if (alias) aliasMap[id] = alias;
|
|
472
|
+
else if (String(id).includes("/")) {
|
|
473
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
474
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
479
|
+
const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
|
|
480
|
+
if (raw && typeof raw === "object") fullAliases = raw;
|
|
481
|
+
} catch {}
|
|
482
|
+
} catch {}
|
|
483
|
+
for (const prov of sortedProvs) {
|
|
484
|
+
const list = groups[prov];
|
|
485
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
486
|
+
for (const id of list) {
|
|
487
|
+
const alias = aliasMap[id];
|
|
488
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
489
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ── 分隔 + 其他供应商 allowlist(原名 + 别名) ──
|
|
493
|
+
// 其余供应商的可用模型由 allowlist 决定(空 allowlist + allowAny OFF = 阻塞),此处直接展示 allowlist 条目与别名,不做网络拉取,速度快
|
|
494
|
+
try {
|
|
495
|
+
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
496
|
+
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../src/providers/model-id.js");
|
|
497
|
+
try { _la2(); } catch {}
|
|
498
|
+
const configs = loadProviderConfigs();
|
|
499
|
+
const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
|
|
500
|
+
// 仅展示已配置过的供应商(有 baseUrl/keys/allowlist 任一),按固定顺序 + 字典序
|
|
501
|
+
const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
|
|
502
|
+
otherIds.sort((a, b) => {
|
|
503
|
+
const ia = order2.indexOf(a), ib = order2.indexOf(b);
|
|
504
|
+
if (ia !== -1 || ib !== -1) {
|
|
505
|
+
if (ia === -1) return 1;
|
|
506
|
+
if (ib === -1) return -1;
|
|
507
|
+
return ia - ib;
|
|
508
|
+
}
|
|
509
|
+
return a.localeCompare(b);
|
|
510
|
+
});
|
|
511
|
+
if (otherIds.length) {
|
|
512
|
+
console.log(`\n────────────────────────────────────────`);
|
|
513
|
+
console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
|
|
514
|
+
for (const pid of otherIds) {
|
|
515
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
516
|
+
const allowAny = loadProviderAllowAnyModels(pid);
|
|
517
|
+
const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
|
|
518
|
+
const header = allowAny
|
|
519
|
+
? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
|
|
520
|
+
: (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
|
|
521
|
+
console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
|
|
522
|
+
if (!allowed.length) {
|
|
523
|
+
if (allowAny) {
|
|
524
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
|
|
525
|
+
console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
526
|
+
} else {
|
|
527
|
+
console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
528
|
+
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
for (const raw of allowed) {
|
|
532
|
+
const canonical = `${pid}/${raw}`;
|
|
533
|
+
let alias = null;
|
|
534
|
+
try { alias = _gaf(canonical); } catch {}
|
|
535
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
536
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
537
|
+
const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
|
|
538
|
+
console.log(` ${pickedMark} ${canonical}${aliasStr}`);
|
|
539
|
+
}
|
|
540
|
+
console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
console.log(`\n────────────────────────────────────────`);
|
|
545
|
+
console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
|
|
546
|
+
console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
|
|
547
|
+
}
|
|
548
|
+
// 额外兜底:本地别名中不在任何 allowlist 里的,单独提示
|
|
549
|
+
const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
|
|
550
|
+
if (ids.includes(canonical)) return false;
|
|
551
|
+
// 已在 allowlist 中展示过的别名不重复
|
|
552
|
+
for (const pid of otherIds) {
|
|
553
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
554
|
+
for (const raw of allowed) {
|
|
555
|
+
const can = `${pid}/${raw}`;
|
|
556
|
+
if (can === canonical) return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
});
|
|
561
|
+
if (aliasEntries.length) {
|
|
562
|
+
console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
|
|
563
|
+
for (const [alias, canonical] of aliasEntries) {
|
|
564
|
+
console.log(` ${canonical} => ${alias}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch {}
|
|
568
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
375
569
|
}
|
|
376
|
-
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
377
570
|
} catch (err) {
|
|
378
571
|
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
379
572
|
process.exit(1);
|
|
@@ -764,17 +957,21 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
764
957
|
const sub = args[idx + 2];
|
|
765
958
|
const rest = args.slice(idx + 2);
|
|
766
959
|
if (!id) {
|
|
767
|
-
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url|allowlist|allowAny]");
|
|
960
|
+
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
|
|
768
961
|
console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
|
|
769
962
|
console.error(" mslxdff -provider openrouter add sk-4 append one key");
|
|
770
963
|
console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
|
|
771
964
|
console.error(" mslxdff -provider openrouter list list all keys (masked)");
|
|
965
|
+
console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
|
|
772
966
|
console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
|
|
773
967
|
console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
|
|
968
|
+
console.error(" mslxdff -provider openrouter set-models-path /v1/models");
|
|
969
|
+
console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
|
|
774
970
|
console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=block unless allowAny ON)");
|
|
775
971
|
console.error(" mslxdff -provider openrouter allowAny on|off empty allowlist = allow all or block all (default OFF, secure)");
|
|
776
972
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
|
|
777
973
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
|
|
974
|
+
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
778
975
|
console.error(" mslxdff -provider openrouter interactive hidden input (append)");
|
|
779
976
|
console.error(" mslxdff -provider openrouter clear remove all keys");
|
|
780
977
|
process.exit(1);
|
|
@@ -832,9 +1029,10 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
832
1029
|
const gBase = rest[1];
|
|
833
1030
|
const gKey = rest[2];
|
|
834
1031
|
if (!gid || !gBase || !gKey) {
|
|
835
|
-
console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]");
|
|
1032
|
+
console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] [--models-path <path>] [--chat-path <path>]");
|
|
836
1033
|
console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
|
|
837
1034
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
|
|
1035
|
+
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
838
1036
|
process.exit(1);
|
|
839
1037
|
}
|
|
840
1038
|
if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
|
|
@@ -1317,7 +1515,8 @@ async function pickInteractive(items, startCursor = 0) {
|
|
|
1317
1515
|
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
1318
1516
|
const draw = () => {
|
|
1319
1517
|
const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
|
|
1320
|
-
|
|
1518
|
+
// PowerShell 兼容:\x1b[2J 清屏 + \x1b[H 回到左上,比 \x1b[G\x1b[J 更可靠
|
|
1519
|
+
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1321
1520
|
};
|
|
1322
1521
|
draw();
|
|
1323
1522
|
return new Promise((resolve) => {
|
|
@@ -1359,7 +1558,7 @@ async function pickInteractiveMulti(items, initialPicked = new Set(), startCurso
|
|
|
1359
1558
|
const draw = () => {
|
|
1360
1559
|
const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
|
|
1361
1560
|
const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
|
|
1362
|
-
process.stdout.write("\x1b[
|
|
1561
|
+
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1363
1562
|
};
|
|
1364
1563
|
draw();
|
|
1365
1564
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -85,6 +85,7 @@ function effectiveCooldown(entry, slowCooldownMs, cooldownMs) {
|
|
|
85
85
|
function inCooldown(id, errors, now, cooldownMs, slowCooldownMs) {
|
|
86
86
|
const e = normEntry(errors[id]);
|
|
87
87
|
if (!e || !(e.at > 0)) return false;
|
|
88
|
+
if (e.status === MODEL_STATUS.NORMAL) return false;
|
|
88
89
|
const cd = effectiveCooldown(e, slowCooldownMs, cooldownMs);
|
|
89
90
|
return cd > 0 && now - e.at < cd;
|
|
90
91
|
}
|
|
@@ -98,6 +99,16 @@ function normLatency(e) {
|
|
|
98
99
|
|
|
99
100
|
export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {}, preferred } = {}) {
|
|
100
101
|
const pref = preferred ?? getPreferredModel();
|
|
102
|
+
// 最近一次成功的模型(NORMAL 且非慢且 at 最大),用于“上次成功优先”——慢模型即使刚成功也不应钉死
|
|
103
|
+
let lastSuccessId = null;
|
|
104
|
+
let lastSuccessAt = 0;
|
|
105
|
+
for (const [id, e] of Object.entries(errors)) {
|
|
106
|
+
const ne = normEntry(e);
|
|
107
|
+
if (ne && ne.status === MODEL_STATUS.NORMAL && !ne.slow && ne.at > lastSuccessAt) {
|
|
108
|
+
lastSuccessAt = ne.at;
|
|
109
|
+
lastSuccessId = id;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
101
112
|
return [...new Set(ids)]
|
|
102
113
|
.filter(Boolean)
|
|
103
114
|
.map((id) => ({
|
|
@@ -105,12 +116,14 @@ export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0,
|
|
|
105
116
|
e: normEntry(errors[id]),
|
|
106
117
|
err: normEntry(errors[id])?.at ?? 0,
|
|
107
118
|
isPreferred: id === pref,
|
|
119
|
+
isLastSuccess: id === lastSuccessId,
|
|
108
120
|
cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
|
|
109
121
|
latency: normLatency(latencies[id]) ?? Number.MAX_SAFE_INTEGER,
|
|
110
122
|
}))
|
|
111
123
|
.sort(
|
|
112
124
|
(a, b) =>
|
|
113
125
|
(a.cooling ? 1 : 0) - (b.cooling ? 1 : 0) ||
|
|
126
|
+
(b.isLastSuccess ? 1 : 0) - (a.isLastSuccess ? 1 : 0) ||
|
|
114
127
|
(b.isPreferred ? 1 : 0) - (a.isPreferred ? 1 : 0) ||
|
|
115
128
|
a.latency - b.latency ||
|
|
116
129
|
a.err - b.err
|
package/src/chat/config.js
CHANGED
|
@@ -6,5 +6,6 @@ export const CHAT_HISTORY_MAX_CHARS = 400000;
|
|
|
6
6
|
export const CHAT_KEEP_RECENT = 40;
|
|
7
7
|
export const CHAT_SUMMARY_TRIGGER = 400000;
|
|
8
8
|
export const CHAT_MAX_TOOL_LOOPS = 6;
|
|
9
|
-
export const CHAT_TIMEOUT_MS =
|
|
9
|
+
export const CHAT_TIMEOUT_MS = 15000;
|
|
10
|
+
export const CHAT_GATEWAY_TIMEOUT_MS = 25000;
|
|
10
11
|
export const FORBIDDEN = ["-uninstall", "--uninstall"];
|
package/src/chat/repl.js
CHANGED
|
@@ -30,35 +30,7 @@ function trace(line) {
|
|
|
30
30
|
console.log(`\x1b[90m· ${line}\x1b[0m`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
const low = String(text || "").toLowerCase();
|
|
35
|
-
const known = ["workbuddy", "clinebot", "opencode", "bai", "openrouter", "poolside", "z-ai", "deepseek"];
|
|
36
|
-
for (const k of known) if (low.includes(k)) return k;
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
function isModelListQuery(text) {
|
|
40
|
-
const low = String(text || "").toLowerCase();
|
|
41
|
-
if (!low.includes("模型")) return false;
|
|
42
|
-
return low.includes("哪些") || low.includes("可用") || low.includes("支持") || low.includes("列表") || low.includes("有啥") || low.includes("都有") || low.includes("可以") || low.includes("用");
|
|
43
|
-
}
|
|
44
|
-
function formatModelAnswer(prov, models) {
|
|
45
|
-
const byProv = {};
|
|
46
|
-
for (const id of models) {
|
|
47
|
-
const slash = id.indexOf("/");
|
|
48
|
-
const p = slash > 0 ? id.slice(0, slash) : "opencode";
|
|
49
|
-
if (!byProv[p]) byProv[p] = [];
|
|
50
|
-
byProv[p].push(id);
|
|
51
|
-
}
|
|
52
|
-
if (prov) {
|
|
53
|
-
const list = byProv[prov] || models.filter((m) => m.toLowerCase().startsWith(prov.toLowerCase() + "/"));
|
|
54
|
-
if (!list.length) return `**${prov}** 暂无可用模型(可能未配置或网关未聚合)。可用总量 ${models.length},按供应商:${Object.entries(byProv).map(([k, v]) => `${k}(${v.length})`).join(" | ")}`;
|
|
55
|
-
// 更友好:直接列 id 和调用方式
|
|
56
|
-
return `**${prov}** 可用模型(共 ${list.length} 个,网关已聚合):\n\n| 模型 id | 调用方式 |\n|:---|:---|\n${list.map((m) => `| \`${m}\` | \`${m}\` |`).join("\n")}\n\n> 提示:直接用 \`${prov}/<模型>\` 调用,例如 \`${list[0]}\``;
|
|
57
|
-
}
|
|
58
|
-
// 无指定供应商:按分组汇总
|
|
59
|
-
const summary = Object.entries(byProv).map(([p, arr]) => `**${p}**(${arr.length}):${arr.slice(0, 8).join(", ")}${arr.length > 8 ? " …" : ""}`).join("\n");
|
|
60
|
-
return `可用模型总计 ${models.length} 个,按供应商分组:\n\n${summary}\n\n> 查某供应商请说“workbuddy有哪些模型”`;
|
|
61
|
-
}
|
|
33
|
+
|
|
62
34
|
|
|
63
35
|
async function maybeCompress(messages) {
|
|
64
36
|
if (!needsCompress(messages)) return [...messages];
|
|
@@ -85,23 +57,11 @@ async function maybeCompress(messages) {
|
|
|
85
57
|
async function runAgentTurn(userText, messages) {
|
|
86
58
|
const tools = getToolDefs();
|
|
87
59
|
messages.push({ role: "user", content: userText });
|
|
88
|
-
// Fast-path:模型列表类问题本地直答,不走 LLM,避免 “provider list” 幻觉和 6 轮重复
|
|
89
|
-
if (isModelListQuery(userText)) {
|
|
90
|
-
const prov = extractProvFromQuery(userText);
|
|
91
|
-
try {
|
|
92
|
-
const models = getModelsForPrompt();
|
|
93
|
-
const answer = formatModelAnswer(prov, models);
|
|
94
|
-
if (answer) {
|
|
95
|
-
messages.push({ role: "assistant", content: answer });
|
|
96
|
-
trace(`[fast] 模型列表直答 prov=${prov || "all"} 共 ${models.length} 个`);
|
|
97
|
-
return { text: answer, model: "local", latency: 0, usage: null, fallback: false, ok: true, totalMs: 0 };
|
|
98
|
-
}
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
60
|
let loops = 0;
|
|
102
61
|
let lastModel = null;
|
|
103
62
|
let lastUsage = null;
|
|
104
63
|
let lastFallback = false;
|
|
64
|
+
let lastFallbackGateway = false;
|
|
105
65
|
let lastLatency = 0;
|
|
106
66
|
const t0 = performance.now();
|
|
107
67
|
const turnStart = performance.now();
|
|
@@ -147,6 +107,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
147
107
|
lastModel = res.model;
|
|
148
108
|
lastUsage = res.usage || null;
|
|
149
109
|
lastFallback = !!res.fallback;
|
|
110
|
+
lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
|
|
150
111
|
const msg = res.message;
|
|
151
112
|
const toolCalls = msg.tool_calls || [];
|
|
152
113
|
let fallbackCmd = null;
|
|
@@ -158,9 +119,11 @@ async function runAgentTurn(userText, messages) {
|
|
|
158
119
|
const text = String(msg.content || "").trim() || "(空回复)";
|
|
159
120
|
messages.push({ role: "assistant", content: text });
|
|
160
121
|
const totalMs = Math.round(performance.now() - t0);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
122
|
+
let note = "";
|
|
123
|
+
if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
|
|
124
|
+
else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
|
|
125
|
+
trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
|
|
126
|
+
return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
|
|
164
127
|
}
|
|
165
128
|
const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
|
|
166
129
|
messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
|
|
@@ -262,19 +225,19 @@ async function runAgentTurn(userText, messages) {
|
|
|
262
225
|
trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
|
|
263
226
|
loops++;
|
|
264
227
|
}
|
|
265
|
-
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: false };
|
|
228
|
+
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
|
|
266
229
|
}
|
|
267
230
|
|
|
268
|
-
function printFooter({ model, latency, usage, totalMs, fallback }) {
|
|
231
|
+
function printFooter({ model, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
|
|
269
232
|
const dim = "\x1b[90m";
|
|
270
233
|
const rst = "\x1b[0m";
|
|
271
234
|
let gw = null;
|
|
272
235
|
try { gw = collectStats(); } catch {}
|
|
273
|
-
const modelLabel = model || "—";
|
|
236
|
+
const modelLabel = model ? (fallbackGateway || viaGateway ? `${model} (gateway auto)` : model) : "—";
|
|
274
237
|
const latLabel = latency ? `${latency}ms` : "—";
|
|
275
238
|
const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
|
|
276
239
|
const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
|
277
|
-
const fbLabel = fallback ? " · fallback" : "";
|
|
240
|
+
const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
|
|
278
241
|
let gwLabel = "";
|
|
279
242
|
let extra = "";
|
|
280
243
|
if (gw) {
|
package/src/chat/stats.js
CHANGED
|
@@ -150,7 +150,7 @@ export function formatBannerLines() {
|
|
|
150
150
|
const green = "\x1b[32m";
|
|
151
151
|
const lines = [];
|
|
152
152
|
lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
|
|
153
|
-
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}
|
|
153
|
+
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
|
|
154
154
|
lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
|
|
155
155
|
const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
|
|
156
156
|
const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
|
package/src/chat/upstream.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS } from "./config.js";
|
|
2
|
+
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS, CHAT_GATEWAY_TIMEOUT_MS } from "./config.js";
|
|
3
3
|
import { createUpstreamClient } from "../upstream.js";
|
|
4
|
+
import { DEFAULT_PORT } from "../state.js";
|
|
4
5
|
|
|
5
6
|
function modelForAttempt(attempt) {
|
|
6
7
|
return attempt === 0 ? CHAT_PREFERRED : CHAT_FALLBACK;
|
|
@@ -56,25 +57,202 @@ async function chatOnceNoTools({ messages, model }) {
|
|
|
56
57
|
} finally { try { await client.close(); } catch {} }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
61
|
+
async function chatViaGateway({ messages, tools }) {
|
|
62
|
+
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
63
|
+
const t0 = TRACE ? performance.now() : 0;
|
|
64
|
+
let port = DEFAULT_PORT;
|
|
65
|
+
let token = "";
|
|
66
|
+
try {
|
|
67
|
+
const state = await import("../state.js");
|
|
68
|
+
const loaded = await state.loadToken();
|
|
69
|
+
token = String(loaded?.token || "").trim();
|
|
70
|
+
const p = state.getPort();
|
|
71
|
+
if (Number.isInteger(p) && p > 0) port = p;
|
|
72
|
+
else if (Number.isInteger(Number(process.env.MSLXDFF_PORT)) && Number(process.env.MSLXDFF_PORT) > 0) port = Number(process.env.MSLXDFF_PORT);
|
|
73
|
+
} catch {}
|
|
74
|
+
// token 为空则尝试直接读 state 文件兜底(避免 loadToken 异常时无 token)
|
|
75
|
+
if (!token) {
|
|
76
|
+
try {
|
|
77
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
78
|
+
const { join } = await import("node:path");
|
|
79
|
+
const { homedir } = await import("node:os");
|
|
80
|
+
const sf = process.env.MSLXDFF_STATE_FILE || join(homedir(), ".config", "mslxdff", "state.json");
|
|
81
|
+
if (existsSync(sf)) {
|
|
82
|
+
const j = JSON.parse(readFileSync(sf, "utf8"));
|
|
83
|
+
if (typeof j.token === "string" && j.token.trim()) token = j.token.trim();
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
|
|
88
|
+
const body = { model: "auto", messages, stream: false };
|
|
89
|
+
if (tools?.length) {
|
|
90
|
+
body.tools = tools;
|
|
91
|
+
body.tool_choice = "auto";
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => controller.abort(), CHAT_GATEWAY_TIMEOUT_MS);
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
});
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
const txt = await res.text();
|
|
104
|
+
let j;
|
|
105
|
+
try { j = JSON.parse(txt); } catch {
|
|
106
|
+
// 网关可能因 workbuddy 强制 stream:true 而返回 SSE(text/event-stream),需兼容
|
|
107
|
+
if (txt.includes("data:")) {
|
|
108
|
+
try {
|
|
109
|
+
const lines = txt.split(/\r?\n/);
|
|
110
|
+
let content = "";
|
|
111
|
+
let model = "auto";
|
|
112
|
+
let usage = null;
|
|
113
|
+
let sseOk = false;
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const t = String(line).trim();
|
|
116
|
+
if (!t.startsWith("data:")) continue;
|
|
117
|
+
const payload = t.slice(5).trim();
|
|
118
|
+
if (!payload || payload === "[DONE]") continue;
|
|
119
|
+
try {
|
|
120
|
+
const obj = JSON.parse(payload);
|
|
121
|
+
sseOk = true;
|
|
122
|
+
const ch = obj.choices?.[0];
|
|
123
|
+
// 兼容 thinking 模型的 reasoning_content(delta 阶段 content 为空,实际在 reasoning_content)
|
|
124
|
+
if (ch?.delta?.content) content += ch.delta.content;
|
|
125
|
+
else if (ch?.delta?.reasoning_content) content += ch.delta.reasoning_content;
|
|
126
|
+
else if (ch?.message?.content) content += ch.message.content;
|
|
127
|
+
else if (ch?.message?.reasoning_content) content += ch.message.reasoning_content;
|
|
128
|
+
else if (typeof ch?.text === "string") content += ch.text;
|
|
129
|
+
else if (typeof obj.content === "string") content += obj.content;
|
|
130
|
+
if (obj.model) model = obj.model;
|
|
131
|
+
if (obj.usage) usage = obj.usage;
|
|
132
|
+
// 有些 SSE 直接是完整 chat.completion
|
|
133
|
+
if (obj.choices?.[0]?.message?.content && !content) content = obj.choices[0].message.content;
|
|
134
|
+
if (obj.choices?.[0]?.message?.reasoning_content && !content) content = obj.choices[0].message.reasoning_content;
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
if (sseOk && content) {
|
|
138
|
+
j = { id: `sse-${Date.now()}`, object: "chat.completion", model, choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content } }], usage };
|
|
139
|
+
} else if (sseOk) {
|
|
140
|
+
// SSE 但无 content,按失败处理
|
|
141
|
+
return { ok: false, error: `gateway SSE no content: ${txt.slice(0, 800)}`, status: res.status };
|
|
142
|
+
} else {
|
|
143
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const msg = j?.error?.message || j?.error || j?.data?.error?.message || j?.data?.error || txt.slice(0, 800);
|
|
154
|
+
if (TRACE) {
|
|
155
|
+
const dt = Math.round(performance.now() - t0);
|
|
156
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · HTTP ${res.status} ${String(msg).slice(0, 80)}\x1b[0m`);
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, error: msg, status: res.status };
|
|
159
|
+
}
|
|
160
|
+
// 兼容网关返回的两种形状:标准 {"choices":...} 与 workbuddy 聚合后的 {"data":{"choices":...}}
|
|
161
|
+
const choice = j.choices?.[0] || j.data?.choices?.[0];
|
|
162
|
+
const effectiveJ = j.choices ? j : (j.data?.choices ? j.data : j);
|
|
163
|
+
if (!choice) {
|
|
164
|
+
if (TRACE) console.log(`\x1b[90m· [gateway debug] no choice, txt=${txt.slice(0, 800)} · j=${JSON.stringify(j).slice(0, 800)}\x1b[0m`);
|
|
165
|
+
return { ok: false, error: `gateway no choice: ${txt.slice(0, 800)}`, status: res.status };
|
|
166
|
+
}
|
|
167
|
+
if (TRACE) {
|
|
168
|
+
const dt = Math.round(performance.now() - t0);
|
|
169
|
+
const m = effectiveJ.model || j.model || choice.message?.model || "auto";
|
|
170
|
+
console.log(`\x1b[90m· [LLM] gateway auto OK · ${dt}ms · 模型 ${m} · 总 ${Math.round(performance.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
171
|
+
}
|
|
172
|
+
// 透传 usage/raw,并标记 gateway(兼容 data 包装)
|
|
173
|
+
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: effectiveJ.model || j.model || "auto", viaGateway: true };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
176
|
+
if (TRACE) {
|
|
177
|
+
const dt = Math.round(performance.now() - t0);
|
|
178
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · ${msg.slice(0, 80)}\x1b[0m`);
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, error: `gateway ${msg}`, status: 502 };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
185
|
+
async function safeChatOnce(opts, model) {
|
|
186
|
+
try {
|
|
187
|
+
const r = await chatOnce({ ...opts, model });
|
|
188
|
+
return r;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
191
|
+
const status = err?._t ? 502 : (err?.cause?.code ? 502 : 502);
|
|
192
|
+
return { ok: false, error: msg, status, _thrown: err };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function isCoolingAsync(id) {
|
|
197
|
+
try {
|
|
198
|
+
const state = await import("../state.js");
|
|
199
|
+
const errors = state.loadModelErrors();
|
|
200
|
+
const e = errors[id];
|
|
201
|
+
if (!e || typeof e !== "object") return false;
|
|
202
|
+
const at = Number(e.at || 0);
|
|
203
|
+
if (!at) return false;
|
|
204
|
+
const isSlow = !!e.slow;
|
|
205
|
+
const cd = isSlow ? 5 * 60 * 1000 : 60 * 1000;
|
|
206
|
+
return Date.now() - at < cd && (e.status === "limit" || e.status === "error");
|
|
207
|
+
} catch { return false; }
|
|
208
|
+
}
|
|
209
|
+
|
|
60
210
|
export async function chatWithFallback(opts) {
|
|
61
211
|
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
62
212
|
const t0 = TRACE ? performance.now() : 0;
|
|
63
|
-
|
|
213
|
+
// 若上次已确认冷却(429/limit),直接跳过,避免 7+7 秒白等,第二次直接走“上次成功”的网关
|
|
214
|
+
const firstCooling = await isCoolingAsync(CHAT_PREFERRED);
|
|
215
|
+
let first;
|
|
216
|
+
if (firstCooling) {
|
|
217
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
|
|
218
|
+
first = { ok: false, error: "skip cooling", status: 429 };
|
|
219
|
+
} else {
|
|
220
|
+
first = await safeChatOnce(opts, CHAT_PREFERRED);
|
|
221
|
+
}
|
|
64
222
|
if (TRACE) {
|
|
65
223
|
const dt = Math.round(performance.now() - t0);
|
|
66
224
|
console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${dt}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
|
|
67
225
|
}
|
|
68
226
|
if (first.ok) return { ...first, model: CHAT_PREFERRED };
|
|
69
227
|
const t1 = TRACE ? performance.now() : 0;
|
|
70
|
-
const
|
|
228
|
+
const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
|
|
229
|
+
let second;
|
|
230
|
+
if (secondCooling && firstCooling) {
|
|
231
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
232
|
+
second = { ok: false, error: "skip cooling", status: 429 };
|
|
233
|
+
} else if (secondCooling) {
|
|
234
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
235
|
+
second = { ok: false, error: "skip cooling", status: 429 };
|
|
236
|
+
} else {
|
|
237
|
+
second = await safeChatOnce(opts, CHAT_FALLBACK);
|
|
238
|
+
}
|
|
71
239
|
if (TRACE) {
|
|
72
240
|
const dt = Math.round(performance.now() - t1);
|
|
73
241
|
const total = Math.round(performance.now() - t0);
|
|
74
242
|
console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${second.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (fallback)\x1b[0m`);
|
|
75
243
|
}
|
|
76
244
|
if (second.ok) return { ...second, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
77
|
-
|
|
245
|
+
// 两者皆失败 → 兜底到本地网关 auto(会走 auto 择优、hedge、peer 等完整链路)
|
|
246
|
+
const t2 = TRACE ? performance.now() : 0;
|
|
247
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} + ${CHAT_FALLBACK} 均失败,尝试本地网关 auto(:8989)\x1b[0m`);
|
|
248
|
+
const third = await chatViaGateway(opts);
|
|
249
|
+
if (TRACE) {
|
|
250
|
+
const dt = Math.round(performance.now() - t2);
|
|
251
|
+
const total = Math.round(performance.now() - t0);
|
|
252
|
+
console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (gateway-fallback)\x1b[0m`);
|
|
253
|
+
}
|
|
254
|
+
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: second.error, viaGateway: true };
|
|
255
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${second.error}; gateway auto failed: ${third.error}`, status: third.status || second.status || first.status };
|
|
78
256
|
}
|
|
79
257
|
|
|
80
258
|
// 压缩用:简短摘要请求(不带 tools),128k 上下文下仅 95% 触发,需完整摘要
|