mslxdff 0.1.77 → 0.1.79

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/autostart.js CHANGED
@@ -159,6 +159,8 @@ async function linuxEnable() {
159
159
  // stop bare detached daemon that may hold the port, let systemd take over (best-effort, wait for port free)
160
160
  try {
161
161
  const { isPidAlive, stopDaemon } = await import("../daemon.js");
162
+ const { resolvePort } = await import("../server.js");
163
+ const port = resolvePort();
162
164
  const pidFile = join(homedir(), ".config", "mslxdff", "daemon.pid");
163
165
  const { existsSync: exists2, readFileSync: read2 } = await import("node:fs");
164
166
  let pidToWait = null;
@@ -177,16 +179,50 @@ async function linuxEnable() {
177
179
  await new Promise((r2) => setTimeout(r2, 200));
178
180
  } else break;
179
181
  }
180
- // best-effort: fuser kill port if still held (some pid without pid file)
181
- try { await execAsync("fuser", ["-k", "8989/tcp"]); } catch {}
182
- // stop any leftover systemd instance before start
182
+ // robust: scan ss for any holder of :port (covers stale pidFile, fuser not installed)
183
+ const killHolders = async () => {
184
+ let killed = 0;
185
+ try {
186
+ const r = await execAsync("ss", ["-lptn", `sport = :${port}`]);
187
+ const out = r.stdout || "";
188
+ const re = /pid=(\d+)/g;
189
+ let m;
190
+ const pids = new Set();
191
+ while ((m = re.exec(out))) pids.add(Number(m[1]));
192
+ // fallback: full ss if sport filter empty (busybox ss)
193
+ if (!pids.size) {
194
+ const r2 = await execAsync("ss", ["-lptn"]);
195
+ const out2 = r2.stdout || "";
196
+ // only consider lines containing :port
197
+ for (const line of out2.split("\n")) {
198
+ if (!line.includes(`:${port}`)) continue;
199
+ const re2 = /pid=(\d+)/g;
200
+ let m2;
201
+ while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
202
+ }
203
+ }
204
+ for (const p of pids) {
205
+ if (p === process.pid) continue;
206
+ try { process.kill(p, "SIGTERM"); killed++; } catch {}
207
+ }
208
+ if (killed) await new Promise((r2) => setTimeout(r2, 400));
209
+ for (const p of pids) {
210
+ try { if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
211
+ }
212
+ } catch {}
213
+ // fuser as extra best-effort (may not exist)
214
+ try { await execAsync("fuser", ["-k", `${port}/tcp`]); } catch {}
215
+ return killed;
216
+ };
217
+ await killHolders();
218
+ // stop any leftover systemd instance before start (avoid double)
183
219
  try { await execAsync("systemctl", ["--user", "stop", SERVICE_NAME]); } catch {}
184
- // wait for :8989 to be free
185
- for (let i = 0; i < 15; i++) {
220
+ // wait for :port to be free (ss probe)
221
+ for (let i = 0; i < 20; i++) {
186
222
  const chk = await execAsync("ss", ["-ltn"]);
187
- if (!chk.stdout.includes(":8989")) break;
188
- await new Promise((r2) => setTimeout(r2, 200));
189
- if (i === 8) try { await execAsync("fuser", ["-k", "8989/tcp"]); } catch {}
223
+ if (!chk.stdout.includes(`:${port}`)) break;
224
+ if (i === 6 || i === 12) await killHolders();
225
+ await new Promise((r2) => setTimeout(r2, 250));
190
226
  }
191
227
  } catch {}
192
228
  let r = await execAsync("systemctl", ["--user", "daemon-reload"]);
@@ -23,6 +23,87 @@ function pad(s, n, align = "left") {
23
23
  return str + " ".repeat(d);
24
24
  }
25
25
 
26
+ export function formatViaReport(results, { peers = [], meta = {}, json = false } = {}) {
27
+ const peerIds = (peers || []).map((p) => p.id || p.url || String(p));
28
+ const samples = meta.samples ?? 1;
29
+ const timeout = meta.timeout ?? 30000;
30
+ const includeOpencode = Boolean(meta.includeOpencode);
31
+ const opencodeTag = includeOpencode ? "opencode=included" : "opencode=skipped";
32
+ const at = meta.at || new Date().toISOString();
33
+ // build json shape
34
+ const jsonObj = {
35
+ meta: { at, samples, timeout, includeOpencode, peers: peerIds, opencodeSkipped: !includeOpencode, ...meta, peers: peerIds },
36
+ results: (results || []).map((r) => ({
37
+ provider: r.provider,
38
+ model: r.model || r.id,
39
+ direct: r.direct ? { ttfb: r.direct.ttfbMs, total: r.direct.totalMs, ok: r.direct.ok, label: r.direct.label, error: r.direct.error } : null,
40
+ via: Object.fromEntries(Object.entries(r.via || {}).map(([k, v]) => [k, v.ok ? { ttfb: v.ttfbMs, total: v.totalMs } : { error: v.label || v.error || "offline", label: v.label }])),
41
+ best: r.best,
42
+ deltaMs: r.deltaMs,
43
+ opencodeSkipped: r.opencodeSkipped,
44
+ })),
45
+ advice: (() => {
46
+ const viaBest = (results || []).find((r) => r.best?.startsWith("via:"));
47
+ if (viaBest) return `${viaBest.provider}/${viaBest.model} 经 ${viaBest.best.slice(4)} 最快`;
48
+ if ((results || []).length) return `${results[0].provider} 走 direct 即可`;
49
+ return "无数据";
50
+ })(),
51
+ };
52
+ if (json) {
53
+ return { text: JSON.stringify(jsonObj, null, 2), json: jsonObj };
54
+ }
55
+ const lines = [];
56
+ lines.push(`bench-via: direct vs via peers (samples=${samples}, timeout=${timeout / 1000}s, ${opencodeTag})`);
57
+ lines.push("");
58
+ const header = `${pad("Provider", 12)} ${pad("Model", 24)} ${pad("direct", 8, "right")} ${peerIds.map((id) => pad(`via ${id}`, 10, "right")).join(" ")} ${pad("best", 14)}`;
59
+ lines.push(header);
60
+ lines.push("─".repeat(header.length));
61
+ for (const r of results || []) {
62
+ const provider = pad(r.provider || "", 12);
63
+ const model = pad(r.model || r.id || "", 24);
64
+ const directOk = r.direct?.ok;
65
+ const directTxt = directOk ? `${r.direct.ttfbMs ?? "—"}ms` : (r.direct?.label || "—");
66
+ // determine best ttfb for ★
67
+ const all = [];
68
+ if (r.direct?.ok) all.push({ key: "direct", ttfb: r.direct.ttfbMs ?? r.direct.totalMs });
69
+ for (const pid of peerIds) {
70
+ const v = r.via?.[pid];
71
+ if (v?.ok) all.push({ key: `via:${pid}`, ttfb: v.ttfbMs ?? v.totalMs });
72
+ }
73
+ let bestKey = r.best;
74
+ if (!bestKey && all.length) bestKey = all.sort((a, b) => a.ttfb - b.ttfb)[0].key;
75
+ const isDirectBest = bestKey === "direct";
76
+ const directCell = pad(`${directTxt}${isDirectBest ? "★" : ""}`, 8, "right");
77
+ const viaCells = peerIds.map((pid) => {
78
+ const v = r.via?.[pid];
79
+ if (!v) return pad("—", 10, "right");
80
+ if (v.ok) {
81
+ const txt = `${v.ttfbMs ?? v.totalMs ?? "—"}ms`;
82
+ const star = bestKey === `via:${pid}` ? "★" : "";
83
+ return pad(`${txt}${star}`, 10, "right");
84
+ }
85
+ const label = v.label || v.error || "offline";
86
+ // map offline label
87
+ const short = label.includes("离线") ? "offline" : label.slice(0, 8);
88
+ return pad(`— ${short}`, 10, "right");
89
+ }).join(" ");
90
+ let bestTxt = r.best || "direct";
91
+ if (r.deltaMs != null && r.best?.startsWith("via:")) {
92
+ const pct = r.direct?.ttfbMs ? Math.round((r.deltaMs / r.direct.ttfbMs) * 100) : 0;
93
+ bestTxt = `${r.best} ${pct}%`;
94
+ }
95
+ lines.push(`${provider} ${model} ${directCell} ${viaCells} ${pad(bestTxt, 14)}`);
96
+ }
97
+ lines.push("─".repeat(header.length));
98
+ const viaBestExample = (results || []).find((r) => r.best?.startsWith("via:"));
99
+ if (viaBestExample) lines.push(`建议:A 经 ${viaBestExample.best.slice(4)} 打 ${viaBestExample.provider} 最快;其余走 direct。`);
100
+ else if ((results || []).length) lines.push(`建议:${results[0].provider} 走 direct 即可。`);
101
+ else lines.push("建议:无数据");
102
+ lines.push(`提示:via 已跳过 opencode(省额度),需对比 opencode 请加 --include-opencode`);
103
+ lines.push(`* via 单样本,仅作参考,多次 --samples 2 取均值更稳`);
104
+ return { text: lines.join("\n"), json: jsonObj };
105
+ }
106
+
26
107
  export function formatReport(results, { json = false } = {}) {
27
108
  const sorted = sortResults(results);
28
109
  const winner = sorted.find((r) => r.ok) || null;
@@ -0,0 +1,87 @@
1
+ import { joinUrl } from "../providers/base.js";
2
+ import { computeMetrics, extractUsageFromJson } from "../metrics.js";
3
+
4
+ function extractInnerMessage(bodyText) {
5
+ const t = String(bodyText || "");
6
+ try {
7
+ const j = JSON.parse(t);
8
+ const m = j?.error?.message || j?.error || j?.message || j?.data?.error || "";
9
+ if (typeof m === "string" && m.trim()) return m.trim().slice(0, 300);
10
+ if (typeof j?.error === "string") return j.error.slice(0, 300);
11
+ } catch {}
12
+ return t.slice(0, 300);
13
+ }
14
+
15
+ function classifyError(status, bodyText) {
16
+ const t = String(bodyText || "").slice(0, 500);
17
+ const low = t.toLowerCase();
18
+ if (status === 401) return { label: "鉴权失败", retryable: false };
19
+ if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
20
+ if (low.includes("only available via cline")) return { label: "仅 Cline 客户端可用", retryable: false };
21
+ if (low.includes("invalid model format")) return { label: "模型格式错误", retryable: false };
22
+ if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
23
+ if (status === 429) return { label: "限流", retryable: true };
24
+ if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
25
+ if (status === 404) return { label: "模型不存在", retryable: false };
26
+ return { label: `HTTP ${status}`, retryable: false };
27
+ }
28
+
29
+ export async function viaProbe({
30
+ peerUrl,
31
+ token,
32
+ providerId,
33
+ model,
34
+ prompt = "hi",
35
+ maxTokens = 5,
36
+ timeoutMs = 30000,
37
+ fetchImpl = globalThis.fetch,
38
+ clock = Date.now,
39
+ } = {}) {
40
+ const started = clock();
41
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
42
+ const base = String(peerUrl || "").replace(/\/+$/, "");
43
+ if (!base) return { ok: false, label: "配置错误", error: "missing peerUrl", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
44
+ if (!model) return { ok: false, label: "配置错误", error: "missing model", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
45
+ let rawModel = String(model).trim();
46
+ if (providerId && rawModel.startsWith(`${providerId}/`)) rawModel = rawModel.slice(providerId.length + 1);
47
+ const url = joinUrl(base, "/v1/chat/completions");
48
+ const body = { model: rawModel, stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
49
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
50
+ if (token) headers.Authorization = `Bearer ${token}`;
51
+ let ttfbMs = null;
52
+ try {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
55
+ const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
56
+ let res;
57
+ try {
58
+ res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
59
+ } finally { clearTimeout(timer); }
60
+ ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
61
+ if (res instanceof Error) throw res;
62
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
63
+ if (!res.ok) {
64
+ let txt = "";
65
+ try { txt = await res.text(); } catch {}
66
+ const cls = classifyError(res.status, txt);
67
+ const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
68
+ return { ok: false, status: res.status, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
69
+ }
70
+ let json = {};
71
+ let txt = "";
72
+ try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
73
+ const usage = extractUsageFromJson(json);
74
+ const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
75
+ const chars = typeof content === "string" ? content.length : 0;
76
+ const promptTokens = usage?.prompt_tokens ?? null;
77
+ const completionTokens = usage?.completion_tokens ?? null;
78
+ const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
79
+ const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
80
+ return { ok: true, status: res.status, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars };
81
+ } catch (e) {
82
+ const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
83
+ const msg = e?.message || String(e);
84
+ const isTimeout = /timeout|abort/i.test(msg);
85
+ return { ok: false, label: isTimeout ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
86
+ }
87
+ }
@@ -0,0 +1,126 @@
1
+ import { viaProbe } from "./via-probe.js";
2
+
3
+ export async function resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log = () => {} } = {}) {
4
+ if (!includeOpencode) return false;
5
+ if (!isTTY) {
6
+ try { log("via 已跳过 opencode(非 TTY,省额度;需对比请在 TTY 加 --include-opencode 并确认)"); } catch {}
7
+ return false;
8
+ }
9
+ if (typeof confirmFn === "function") {
10
+ try {
11
+ const ok = await confirmFn();
12
+ // confirmFn returns true for y, false otherwise
13
+ if (ok) return true;
14
+ try { log("已回落:via 跳过 opencode"); } catch {}
15
+ return false;
16
+ } catch { return false; }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ export async function getOnlinePeers({ loadGroupsJoined, loadPeers, probeHealth } = {}) {
22
+ let candidates = [];
23
+ if (typeof loadPeers === "function") {
24
+ try { candidates = loadPeers() || []; } catch { candidates = []; }
25
+ } else {
26
+ try {
27
+ const m = await import("../state.js");
28
+ if (m.loadPeers) candidates = m.loadPeers() || [];
29
+ } catch {}
30
+ }
31
+ // also consider groupsJoined for validation - if no groups joined, via is meaningless, but we still allow static peers?
32
+ // For bench-via, if no groupsJoined at all, treat as no via capability -> return [] without probing?
33
+ // However static peers may exist without group; we probe them anyway.
34
+ // To satisfy "empty group -> skip probing" test, check loadGroupsJoined.
35
+ let joined = [];
36
+ if (typeof loadGroupsJoined === "function") {
37
+ try { joined = loadGroupsJoined() || []; } catch { joined = []; }
38
+ } else {
39
+ try {
40
+ const m = await import("../state.js");
41
+ if (m.loadGroupsJoined) joined = m.loadGroupsJoined() || [];
42
+ } catch {}
43
+ }
44
+ if (!joined.length) return [];
45
+ if (!candidates.length) return [];
46
+
47
+ // If probeHealth not provided, try import
48
+ let probe = probeHealth;
49
+ if (!probe) {
50
+ try {
51
+ const m = await import("../cli/group-helpers.js");
52
+ probe = m.probeHealth;
53
+ } catch { return candidates; }
54
+ }
55
+ if (!candidates.length) return [];
56
+ const online = [];
57
+ for (const p of candidates) {
58
+ try {
59
+ const r = await probe(p);
60
+ if (r && r.rank === 0 && !r.fail && !r.stale) online.push(p);
61
+ } catch {}
62
+ }
63
+ return online;
64
+ }
65
+
66
+ export async function orchestrateVia({
67
+ models = [],
68
+ peers = [],
69
+ directRunner = null,
70
+ viaProbeFn = viaProbe,
71
+ includeOpencode = false,
72
+ token,
73
+ timeoutMs = 30000,
74
+ clock = Date.now,
75
+ } = {}) {
76
+ const filtered = includeOpencode ? models : models.filter((m) => {
77
+ const s = typeof m === "string" ? m : (m.id || m.model || m.provider || "");
78
+ const first = String(s).split("/")[0].toLowerCase();
79
+ const prov = String(m?.provider || m?.providerId || first).toLowerCase();
80
+ return prov !== "opencode" && first !== "opencode";
81
+ });
82
+ // normalize model entries
83
+ const normModels = filtered.map((m) => {
84
+ if (typeof m === "string") return { provider: m.split("/")[0], model: m, id: m };
85
+ const id = m.id || m.model || "";
86
+ const provider = m.provider || m.providerId || (id.includes("/") ? id.split("/")[0] : "");
87
+ return { provider, model: id, id };
88
+ });
89
+ const results = [];
90
+ for (const entry of normModels) {
91
+ const provider = entry.provider;
92
+ const model = entry.model;
93
+ // direct
94
+ let direct = null;
95
+ if (directRunner) {
96
+ try { direct = await directRunner({ provider, model, timeoutMs, clock }); } catch (e) { direct = { ok: false, label: "网络错误", error: String(e), ttfbMs: null, totalMs: 0 }; }
97
+ } else {
98
+ direct = { ok: false, label: "未配置 directRunner", error: "missing directRunner", ttfbMs: null, totalMs: 0 };
99
+ }
100
+ const via = {};
101
+ for (const peer of peers) {
102
+ const peerId = peer.id || peer.url || "peer";
103
+ try {
104
+ const r = await viaProbeFn({ peerUrl: peer.url, token, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
105
+ via[peerId] = r;
106
+ } catch (e) {
107
+ via[peerId] = { ok: false, label: "网络错误", error: String(e?.message || e), ttfbMs: null, totalMs: 0 };
108
+ }
109
+ }
110
+ // best
111
+ let best = "direct";
112
+ let bestTtfb = direct?.ok ? (direct.ttfbMs ?? direct.totalMs) : Infinity;
113
+ let deltaMs = null;
114
+ for (const [pid, rv] of Object.entries(via)) {
115
+ if (!rv.ok) continue;
116
+ const t = rv.ttfbMs ?? rv.totalMs;
117
+ if (t < bestTtfb) { bestTtfb = t; best = `via:${pid}`; }
118
+ }
119
+ if (best.startsWith("via:")) {
120
+ const d = direct?.ttfbMs ?? direct?.totalMs ?? 0;
121
+ deltaMs = bestTtfb - d;
122
+ }
123
+ results.push({ provider, model, direct, via, best, deltaMs, opencodeSkipped: !includeOpencode });
124
+ }
125
+ return results;
126
+ }
@@ -1,13 +1,11 @@
1
1
  import { probeModels } from "../../../bench/probe.js";
2
2
  import { runOne } from "../../../bench/runner.js";
3
- import { formatReport } from "../../../bench/report.js";
3
+ import { formatReport, formatViaReport } from "../../../bench/report.js";
4
4
  import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
5
5
  import { isRefreshToken, clineHeaders } from "../../../providers/cline/headers.js";
6
6
  import { refreshTokenForBase } from "../../../providers/cline/auth.js";
7
7
  import { computeMetrics } from "../../../metrics.js";
8
8
 
9
- // Cline 免费通道(deepseek/z-ai 等)非流式会被上游限流 500 empty response content,
10
- // 必须 stream:true + SSE 聚合后测速
11
9
  async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, timeoutMs, fetchImpl }) {
12
10
  const controller = new AbortController();
13
11
  const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
@@ -25,7 +23,6 @@ async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, t
25
23
  if (!res.ok) {
26
24
  let txt = "";
27
25
  try { txt = await res.text(); } catch {}
28
- const low = txt.toLowerCase();
29
26
  const label = res.status === 401 ? "鉴权失败" : res.status === 429 ? "限流" : res.status >= 500 ? `上游错误 ${res.status}` : `HTTP ${res.status}`;
30
27
  return { id: model, ok: false, status: res.status, label, error: txt.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
31
28
  }
@@ -59,16 +56,18 @@ async function clineBenchOne({ baseUrl, model, accessToken, prompt, maxTokens, t
59
56
  } catch (e) {
60
57
  const msg = e?.message || String(e);
61
58
  return { id: model, ok: false, label: /timeout|abort/i.test(msg) ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs: Math.round(performance.now() - t0), tps: null, charsPerSec: null, tokens: null };
62
- } finally {
63
- clearTimeout(timer);
64
- }
59
+ } finally { clearTimeout(timer); }
65
60
  }
66
61
 
67
62
  function parseBenchArgs(rest) {
68
- const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
63
+ const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000, via: false, includeOpencode: false, samples: 1 };
69
64
  for (let i = 0; i < rest.length; i++) {
70
65
  const a = rest[i];
71
66
  if (a === "--json" || a === "-json") opts.json = true;
67
+ else if (a === "--via" || a === "--bench-via") opts.via = true;
68
+ else if (a === "--include-opencode") opts.includeOpencode = true;
69
+ else if (a === "--samples" && rest[i + 1]) opts.samples = Number(rest[++i]) || 1;
70
+ else if (a.startsWith("--samples=")) opts.samples = Number(a.split("=")[1]) || 1;
72
71
  else if (a === "--prompt" && rest[i + 1]) opts.prompt = rest[++i];
73
72
  else if (a.startsWith("--prompt=")) opts.prompt = a.slice(9);
74
73
  else if (a === "--max-tokens" && rest[i + 1]) opts.maxTokens = Number(rest[++i]) || 32;
@@ -91,33 +90,141 @@ function buildHeadersForProvider(providerId, apiKey, auth) {
91
90
  if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
92
91
  if (auth?.uid) h["X-User-Id"] = auth.uid;
93
92
  h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
94
- if (auth?.enterpriseId) {
95
- h["X-Enterprise-Id"] = auth.enterpriseId;
96
- h["X-Tenant-Id"] = auth.enterpriseId;
97
- }
93
+ if (auth?.enterpriseId) { h["X-Enterprise-Id"] = auth.enterpriseId; h["X-Tenant-Id"] = auth.enterpriseId; }
98
94
  return h;
99
95
  }
100
96
  if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
101
97
  return h;
102
98
  }
103
99
 
100
+ async function handleVia({ providerId, opts, fetchImpl, loadConfigs, loadKeys, loadAllowed, loadBaseUrl }) {
101
+ const { getOnlinePeers, orchestrateVia, resolveIncludeOpencode } = await import("../../../bench/via.js");
102
+ const peers = await getOnlinePeers();
103
+ if (!peers.length) {
104
+ const msg = "未加入组或无在线 peer,--via 无意义。先 mslxdff -group list / -addtogroup";
105
+ if (opts.json) console.log(JSON.stringify({ meta: { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode: false, peers: [], opencodeSkipped: true }, results: [], advice: msg }, null, 2));
106
+ else console.log(msg);
107
+ process.exit(0);
108
+ }
109
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
110
+ let includeOpencode = opts.includeOpencode;
111
+ if (includeOpencode) {
112
+ const confirmFn = async () => {
113
+ const readline = await import("node:readline");
114
+ return new Promise((res) => {
115
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
116
+ rl.question("将消耗 B/C/D 的 opencode 额度,确认测 opencode via?y/N ", (ans) => { rl.close(); res(ans.trim().toLowerCase() === "y"); });
117
+ });
118
+ };
119
+ const log = (s) => (opts.json ? console.error(s) : console.log(s));
120
+ includeOpencode = await resolveIncludeOpencode({ includeOpencode, isTTY, confirmFn, log });
121
+ }
122
+ const { loadToken } = await import("../../../state.js");
123
+ let token = "";
124
+ try { token = (await loadToken()).token || ""; } catch {}
125
+ // target providers
126
+ let targetIds = [];
127
+ const isAll = providerId === "bench" || providerId === "all";
128
+ if (isAll) {
129
+ const configs = loadConfigs();
130
+ const ids = new Set(Object.keys(configs));
131
+ ids.add("opencode"); ids.add("openrouter");
132
+ try { const m = await import("../../../state.js"); const raw = m.loadProviderKeys ? null : null; } catch {}
133
+ // collect from providerKeys via loadKeys probing? simple: iterate ids and keep those with allowed
134
+ for (const pid of [...ids]) {
135
+ const allowed = loadAllowed(pid) || [];
136
+ if (allowed.length) targetIds.push(pid);
137
+ else if (pid === "opencode" && !includeOpencode) continue;
138
+ }
139
+ // also check generic ids from state raw
140
+ try {
141
+ const { readFileSync } = await import("node:fs");
142
+ const { defaultStateFile } = await import("../../../state.js");
143
+ const raw = JSON.parse(readFileSync(defaultStateFile(), "utf8"));
144
+ for (const k of Object.keys(raw.providerConfigs || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
145
+ for (const k of Object.keys(raw.providerKeys || {})) if (!targetIds.includes(k) && (loadAllowed(k) || []).length) targetIds.push(k);
146
+ } catch {}
147
+ if (!targetIds.length) targetIds = ["openrouter", "workbuddy", "clinebot"].filter((p) => (loadAllowed(p) || []).length);
148
+ } else {
149
+ targetIds = [providerId];
150
+ }
151
+ const allResults = [];
152
+ const viaLog = (s) => (opts.json ? console.error(s) : console.log(s));
153
+ viaLog(`bench-via: direct vs via peers (samples=${opts.samples}, timeout=${opts.timeoutMs}ms, ${includeOpencode ? "opencode=included" : "opencode=skipped"}) peers=${peers.map((p) => p.id).join(",")}`);
154
+ for (const pid of targetIds) {
155
+ const cfg = (loadConfigs()[pid] || {});
156
+ const keys = loadKeys(pid) || [];
157
+ const allowed = loadAllowed(pid) || [];
158
+ const baseUrl = (loadBaseUrl(pid) || cfg.baseUrl || "").trim();
159
+ if (!allowed.length) {
160
+ viaLog(`provider ${pid}: 无勾选模型,跳过`);
161
+ continue;
162
+ }
163
+ if (!baseUrl && pid !== "opencode") { viaLog(`provider ${pid}: missing baseUrl 跳过`); continue; }
164
+ if (!keys.length && pid !== "opencode") { viaLog(`provider ${pid}: 未配置 Key 跳过`); continue; }
165
+ const chatPath = cfg.chatPath || defaultChatPath(pid);
166
+ let auths = [];
167
+ try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(pid) : []; } catch {}
168
+ const models = allowed.map((id) => ({ provider: pid, model: String(id), id: String(id) }));
169
+ // directRunner per provider
170
+ const directRunner = async ({ provider, model }) => {
171
+ const p = provider || pid;
172
+ const idx = models.findIndex((x) => x.model === model);
173
+ const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
174
+ const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
175
+ const key = keys[kIdx] || keys[0] || "";
176
+ const auth = auths[aIdx] || auths[0] || null;
177
+ const cKeys = keys.filter((k) => isRefreshToken(k));
178
+ if (cKeys.length) {
179
+ const normBase = String(baseUrl).replace(/\/+$/, "");
180
+ const chatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
181
+ const rt = cKeys[kIdx % cKeys.length];
182
+ const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
183
+ if (!at) return { ok: false, label: "鉴权失败", error: "refresh failed", ttfbMs: null, totalMs: 0 };
184
+ return clineBenchOne({ baseUrl: chatBase, model, accessToken: at, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
185
+ }
186
+ const headers = buildHeadersForProvider(p, key, auth);
187
+ return runOne({ baseUrl: p === "opencode" ? (process.env.UPSTREAM_BASE_URL || "https://opencode.ai") : baseUrl, chatPath, model, providerId: p, apiKey: key || "public", headers, prompt: "hi", maxTokens: 5, timeoutMs: opts.timeoutMs, fetchImpl });
188
+ };
189
+ const { viaProbe } = await import("../../../bench/via-probe.js");
190
+ const viaProbeFn = (args) => viaProbe({ ...args, token, fetchImpl });
191
+ const part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs });
192
+ allResults.push(...part);
193
+ if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成`);
194
+ }
195
+ const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
196
+ const report = formatViaReport(allResults, { peers, meta, json: opts.json });
197
+ if (opts.json) console.log(report.text);
198
+ else console.log("\n" + report.text);
199
+ process.exit(0);
200
+ }
201
+
104
202
  export async function handleProviderBench(id, sub, rest, args, deps = {}) {
105
- const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test";
203
+ const _restArr = rest || [];
204
+ const _hasVia = _restArr.includes("--via") || _restArr.includes("--bench-via");
205
+ const _isBenchViaAll = (String(id) === "bench" || String(id) === "all") && _hasVia;
206
+ const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test" || _isBenchViaAll;
106
207
  if (!isBench) return false;
208
+ const opts = parseBenchArgs(_restArr);
209
+ // via branch
210
+ if (opts.via) {
211
+ const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
212
+ const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
213
+ const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
214
+ const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
215
+ const viaPid = _isBenchViaAll ? "bench" : String(id || "").trim();
216
+ if (!viaPid) { console.error("usage: mslxdff -provider <id> bench --via [--json] [--include-opencode]"); process.exit(1); }
217
+ await handleVia({ providerId: viaPid, opts, fetchImpl: deps.fetchImpl || globalThis.fetch, loadConfigs, loadKeys, loadAllowed, loadBaseUrl });
218
+ return true;
219
+ }
107
220
  const fetchImpl = deps.fetchImpl || globalThis.fetch;
108
221
  const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
109
222
  const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
110
223
  const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
111
224
  const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
112
225
  const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
113
- const loadAuths = deps.loadProviderAuths || (async () => { try { const m = await import("../../../state.js"); return m.loadProviderAuths ? m.loadProviderAuths(id) : []; } catch { return []; } }) && (await import("../../../state.js")).loadProviderAuths;
114
-
115
226
  const providerId = String(id || "").trim();
116
- if (!providerId) {
117
- console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]");
118
- process.exit(1);
119
- }
120
- const opts = parseBenchArgs(rest || []);
227
+ if (!providerId) { console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]"); process.exit(1); }
121
228
  const configs = loadConfigs();
122
229
  const cfg = configs[providerId] || {};
123
230
  const keys = loadKeys(providerId) || [];
@@ -126,38 +233,19 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
126
233
  const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
127
234
  let auths = [];
128
235
  try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(providerId) : []; } catch {}
129
- if (!baseUrl) {
130
- console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`);
131
- process.exit(1);
132
- }
133
- if (!keys.length) {
134
- console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`);
135
- process.exit(1);
136
- }
137
- // 空勾选 → 不发 chat,仅探活模型列表并提示
236
+ if (!baseUrl) { console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`); process.exit(1); }
237
+ if (!keys.length) { console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`); process.exit(1); }
138
238
  if (!allowed.length) {
139
239
  const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
140
240
  console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
141
241
  console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
142
242
  const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
143
243
  const probed = await probeModels({ baseUrl, modelsPath, headers, fetchImpl, timeoutMs: 8000 });
144
- if (!probed.ok) {
145
- console.error(`探活失败:${probed.error}`);
146
- console.error(`已尝试:${probed.tried.join(", ")}`);
147
- console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`);
148
- if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2));
149
- process.exit(1);
150
- }
244
+ if (!probed.ok) { console.error(`探活失败:${probed.error}`); console.error(`已尝试:${probed.tried.join(", ")}`); console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`); if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2)); process.exit(1); }
151
245
  const list = probed.data || [];
152
- if (!list.length) {
153
- console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models");
154
- if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2));
155
- process.exit(0);
156
- }
246
+ if (!list.length) { console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models"); if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2)); process.exit(0); }
157
247
  console.log(`\n发现 ${list.length} 个模型:`);
158
- for (const m of list.slice(0, 30)) {
159
- console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
160
- }
248
+ for (const m of list.slice(0, 30)) console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
161
249
  if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
162
250
  console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
163
251
  console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
@@ -165,14 +253,10 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
165
253
  if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
166
254
  process.exit(0);
167
255
  }
168
-
169
- // 有勾选 → 逐个测
170
256
  const log = (s) => (opts.json ? console.error(s) : console.log(s));
171
257
  log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
172
258
  if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
173
259
  const chatPath = cfg.chatPath || defaultChatPath(providerId);
174
- // Cline 新链:keys 含 refreshToken 时走 refresh→workos token + 指纹头(绕 403/401),
175
- // 且 chat 固定拼 https://<host>/api/v1/chat/completions(剥掉 baseUrl 里可能带的 /api/v1)
176
260
  const rtKeys = keys.filter((k) => isRefreshToken(k));
177
261
  const normBase = String(baseUrl).replace(/\/+$/, "");
178
262
  const clineChatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
@@ -181,46 +265,27 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
181
265
  const raw = allowed[i];
182
266
  const model = String(raw || "").trim();
183
267
  if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
184
- // 轮询 key/auth:按索引取,超长循环
185
268
  const kIdx = i % (keys.length || 1);
186
269
  const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
187
270
  const key = keys[kIdx];
188
271
  const auth = auths[aIdx] || auths[0] || null;
189
-
190
272
  if (rtKeys.length) {
191
273
  const rt = rtKeys[kIdx % rtKeys.length];
192
274
  const at = await refreshTokenForBase({ refreshToken: rt, baseUrl: normBase, fetchImpl });
193
- if (!at) {
194
- const r = { id: model, ok: false, error: "refreshToken 换 accessToken 失败", label: "鉴权失败", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
195
- results.push(r);
196
- if (!opts.json) console.log(`FAIL ${r.label} (${r.error})`);
197
- continue;
198
- }
275
+ if (!at) { const r = { id: model, ok: false, error: "refreshToken 换 accessToken 失败", label: "鉴权失败", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null }; results.push(r); if (!opts.json) console.log(`FAIL ${r.label} (${r.error})`); continue; }
199
276
  const r = await clineBenchOne({ baseUrl: clineChatBase, model, accessToken: at, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
200
277
  results.push(r);
201
- if (!opts.json) {
202
- if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`);
203
- else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
204
- }
278
+ if (!opts.json) { if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`); else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`); }
205
279
  continue;
206
280
  }
207
-
208
281
  const headers = buildHeadersForProvider(providerId, key, auth);
209
282
  const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
210
283
  results.push(r);
211
- if (!opts.json) {
212
- if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`);
213
- else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
214
- }
284
+ if (!opts.json) { if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`); else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`); }
215
285
  }
216
286
  const report = formatReport(results, { json: opts.json });
217
287
  console.log("\n" + report.text);
218
- if (opts.json) {
219
- // json 已在 text 中输出一次(report.text 是 JSON),无需重复
220
- }
221
288
  const failed = results.filter((r) => !r.ok).length;
222
- if (failed && !opts.json) {
223
- console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
224
- }
289
+ if (failed && !opts.json) console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
225
290
  process.exit(failed ? 2 : 0);
226
291
  }
@@ -0,0 +1,84 @@
1
+ import { autoUpdateIntervalMs } from "../cli/policy.js";
2
+ import { errMsg, npmCmd, run } from "../cli/util.js";
3
+ import { resolvePort } from "../server.js";
4
+
5
+ export function setupAutoUpdate({ VERSION, bus, logs }) {
6
+ const autoUpdateMs = autoUpdateIntervalMs();
7
+ function emitAutoUpdate(type, data = {}) {
8
+ const entry = { ts: Date.now(), type, ...data };
9
+ try { bus?.emit(entry); } catch {}
10
+ try { logs?.appendEvent?.(entry); } catch {}
11
+ const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
12
+ console.log(line);
13
+ }
14
+ if (autoUpdateMs) {
15
+ console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
16
+ emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
17
+ setTimeout(() => {
18
+ emitAutoUpdate("auto-update-check", { current: VERSION });
19
+ checkAndAutoUpdate().catch((err) => {
20
+ console.log(`auto-update check failed: ${errMsg(err)}`);
21
+ emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
22
+ });
23
+ }, 30_000).unref?.();
24
+ const autoUpdateTimer = setInterval(() => {
25
+ emitAutoUpdate("auto-update-check", { current: VERSION });
26
+ checkAndAutoUpdate().catch((err) => {
27
+ console.log(`auto-update check failed: ${errMsg(err)}`);
28
+ emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
29
+ });
30
+ }, autoUpdateMs);
31
+ autoUpdateTimer.unref();
32
+ } else {
33
+ console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
34
+ emitAutoUpdate("auto-update-disabled", { current: VERSION });
35
+ }
36
+
37
+ async function checkAndAutoUpdate() {
38
+ emitAutoUpdate("auto-update-query", { current: VERSION });
39
+ const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
40
+ if (info.err) {
41
+ emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
42
+ throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
43
+ }
44
+ let latest = "";
45
+ try {
46
+ latest = JSON.parse(String(info.stdout || "").trim());
47
+ if (Array.isArray(latest)) latest = latest[latest.length - 1];
48
+ latest = String(latest || "").replace(/^v/, "").trim();
49
+ } catch {
50
+ const raw = String(info.stdout || "").trim();
51
+ const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
52
+ latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
53
+ }
54
+ latest = latest.replace(/['"]/g, "").trim();
55
+ emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
56
+ if (!latest || latest === VERSION) {
57
+ emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
58
+ return;
59
+ }
60
+ const { compareSemver } = await import("../cli/policy.js");
61
+ if (compareSemver(latest, VERSION) <= 0) {
62
+ emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
63
+ return;
64
+ }
65
+ emitAutoUpdate("auto-update-found", { current: VERSION, latest });
66
+ console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
67
+ emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
68
+ const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
69
+ if (up.err) {
70
+ emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
71
+ throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
72
+ }
73
+ emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
74
+ console.log(`auto-update: installed v${latest}, restarting daemon...`);
75
+ emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
76
+ const { stopDaemon, startDaemon } = await import("../daemon.js");
77
+ try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
78
+ const { waitForHealth } = await import("../cli/policy.js");
79
+ const newPid = startDaemon([]);
80
+ await waitForHealth(resolvePort(), 8000);
81
+ console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
82
+ emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
83
+ }
84
+ }
@@ -191,7 +191,46 @@ export async function startDaemonMain(VERSION) {
191
191
  process.on("SIGTERM", restore2);
192
192
  }
193
193
 
194
- await srv.ready();
194
+ // Robust ready: if EADDRINUSE (bare daemon still holds port), kill holders and retry once
195
+ try {
196
+ await srv.ready();
197
+ } catch (err) {
198
+ const msg = String(err?.message || err);
199
+ const code = err?.code || "";
200
+ if (code === "EADDRINUSE" || msg.includes("EADDRINUSE")) {
201
+ console.log(`port ${resolvePort()} in use — freeing stale holder and retrying...`);
202
+ try {
203
+ const { execFile } = await import("node:child_process");
204
+ const execAsync2 = (f, a) => new Promise((res) => execFile(f, a, { windowsHide: true, timeout: 4000 }, (e, so, se) => res({ e, so: String(so||""), se: String(se||"") })));
205
+ const port = resolvePort();
206
+ // kill via ss parse (same as autostart)
207
+ const ss1 = await execAsync2("ss", ["-lptn", `sport = :${port}`]);
208
+ const out = ss1.so || "";
209
+ const pids = new Set();
210
+ let m;
211
+ const re = /pid=(\d+)/g;
212
+ while ((m = re.exec(out))) pids.add(Number(m[1]));
213
+ if (!pids.size) {
214
+ const ss2 = await execAsync2("ss", ["-lptn"]);
215
+ for (const line of (ss2.so||"").split("\n")) {
216
+ if (!line.includes(`:${port}`)) continue;
217
+ let m2; const re2 = /pid=(\d+)/g;
218
+ while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
219
+ }
220
+ }
221
+ for (const p of pids) { if (p !== process.pid) try { process.kill(p, "SIGTERM"); } catch {} }
222
+ if (pids.size) await new Promise((r2) => setTimeout(r2, 600));
223
+ for (const p of pids) try { const { isPidAlive } = await import("../daemon.js"); if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
224
+ try { await execAsync2("fuser", ["-k", `${port}/tcp`]); } catch {}
225
+ for (let i=0;i<10;i++) {
226
+ const chk = await execAsync2("ss", ["-ltn"]);
227
+ if (!chk.so.includes(`:${port}`)) break;
228
+ await new Promise((r2)=>setTimeout(r2,200));
229
+ }
230
+ } catch {}
231
+ await srv.ready();
232
+ } else throw err;
233
+ }
195
234
 
196
235
  if (loadedPlugins.length) {
197
236
  runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
@@ -233,6 +272,26 @@ export async function startDaemonMain(VERSION) {
233
272
  console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
234
273
  } catch {}
235
274
 
275
+ // best-effort: ensure autostart on Linux (so daemon survives reboot/SSH disconnect without manual cmd)
276
+ if (process.platform === "linux" && !process.env.MSLXDFF_NO_AUTOSTART) {
277
+ setTimeout(async () => {
278
+ try {
279
+ const { getAutostartStatus, enableAutostart } = await import("../autostart.js");
280
+ const st = await getAutostartStatus();
281
+ if (!st.enabled) {
282
+ const r = await enableAutostart();
283
+ if (r.ok) {
284
+ console.log(`autostart auto-enabled: ${r.method}${r.linger ? ` linger=${r.linger}` : ""}`);
285
+ try { bus?.emit({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
286
+ try { appendEvent({ ts: Date.now(), type: "autostart-auto-enabled", method: r.method }); } catch {}
287
+ } else {
288
+ console.log(`autostart auto-enable failed: ${r.error || "unknown"} (run mslxdff -enable-autostart manually)`);
289
+ }
290
+ }
291
+ } catch {}
292
+ }, 2500).unref?.();
293
+ }
294
+
236
295
  // group sync
237
296
  const { syncAllJoinedGroups } = await import("../cli/group-helpers.js");
238
297
  syncAllJoinedGroups({ peers, groups })
@@ -329,84 +388,8 @@ export async function startDaemonMain(VERSION) {
329
388
  console.log(`broadband relay: heartbeat 30s + poll 1s for ${broadbandGroups().length} group(s)`);
330
389
  }
331
390
 
332
- const autoUpdateMs = autoUpdateIntervalMs();
333
- function emitAutoUpdate(type, data = {}) {
334
- const entry = { ts: Date.now(), type, ...data };
335
- try { bus?.emit(entry); } catch {}
336
- try { logs?.appendEvent?.(entry); } catch {}
337
- const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
338
- console.log(line);
339
- }
340
- if (autoUpdateMs) {
341
- console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
342
- emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
343
- setTimeout(() => {
344
- emitAutoUpdate("auto-update-check", { current: VERSION });
345
- checkAndAutoUpdate().catch((err) => {
346
- console.log(`auto-update check failed: ${errMsg(err)}`);
347
- emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
348
- });
349
- }, 30_000).unref?.();
350
- const autoUpdateTimer = setInterval(() => {
351
- emitAutoUpdate("auto-update-check", { current: VERSION });
352
- checkAndAutoUpdate().catch((err) => {
353
- console.log(`auto-update check failed: ${errMsg(err)}`);
354
- emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
355
- });
356
- }, autoUpdateMs);
357
- autoUpdateTimer.unref();
358
- } else {
359
- console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
360
- emitAutoUpdate("auto-update-disabled", { current: VERSION });
361
- }
362
-
363
- async function checkAndAutoUpdate() {
364
- emitAutoUpdate("auto-update-query", { current: VERSION });
365
- const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
366
- if (info.err) {
367
- emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
368
- throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
369
- }
370
- let latest = "";
371
- try {
372
- latest = JSON.parse(String(info.stdout || "").trim());
373
- if (Array.isArray(latest)) latest = latest[latest.length - 1];
374
- latest = String(latest || "").replace(/^v/, "").trim();
375
- } catch {
376
- const raw = String(info.stdout || "").trim();
377
- const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
378
- latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
379
- }
380
- latest = latest.replace(/['"]/g, "").trim();
381
- emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
382
- if (!latest || latest === VERSION) {
383
- emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
384
- return;
385
- }
386
- const { compareSemver } = await import("../cli/policy.js");
387
- if (compareSemver(latest, VERSION) <= 0) {
388
- emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
389
- return;
390
- }
391
- emitAutoUpdate("auto-update-found", { current: VERSION, latest });
392
- console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
393
- emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
394
- const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
395
- if (up.err) {
396
- emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
397
- throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
398
- }
399
- emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
400
- console.log(`auto-update: installed v${latest}, restarting daemon...`);
401
- emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
402
- const { stopDaemon, startDaemon } = await import("../daemon.js");
403
- try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
404
- const { waitForHealth } = await import("../cli/policy.js");
405
- const newPid = startDaemon([]);
406
- await waitForHealth(resolvePort(), 8000);
407
- console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
408
- emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
409
- }
391
+ const { setupAutoUpdate } = await import("./auto-update.js");
392
+ setupAutoUpdate({ VERSION, bus, logs });
410
393
  }
411
394
 
412
395
 
package/src/upstream.js CHANGED
@@ -59,13 +59,30 @@ export function createUpstreamClient({
59
59
  return null;
60
60
  }
61
61
  };
62
- const baseHeaders = {
63
- "Content-Type": "application/json",
64
- "Authorization": `Bearer ${authToken}`,
65
- "x-opencode-client": "desktop",
66
- };
62
+ function isAnonFirst() {
63
+ const raw = process.env.MSLXDFF_OPENCOD_ANON_FIRST ?? process.env.MSLXDFF_ANON_FIRST ?? process.env.MSLXDFF_OPENCOD_ANON;
64
+ if (raw === undefined || raw === null || raw === "") return authToken === "public";
65
+ const s = String(raw).trim().toLowerCase();
66
+ if (s === "0" || s === "false" || s === "off" || s === "no" || s === "disable" || s === "disabled") return false;
67
+ return authToken === "public";
68
+ }
69
+ const anonFirst = isAnonFirst();
70
+ const baseHeaders = anonFirst
71
+ ? {
72
+ "Content-Type": "application/json",
73
+ "Authorization": "",
74
+ "x-opencode-client": "desktop",
75
+ "User-Agent": "opencode",
76
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
77
+ "X-Title": "Hermes Agent",
78
+ }
79
+ : {
80
+ "Content-Type": "application/json",
81
+ "Authorization": `Bearer ${authToken}`,
82
+ "x-opencode-client": "desktop",
83
+ };
67
84
 
68
- function buildHeaders(body, { anonymous = false } = {}) {
85
+ function buildHeaders(body, { anonymous = anonFirst } = {}) {
69
86
  const isStream = body?.stream !== false;
70
87
  const base = {
71
88
  ...baseHeaders,
@@ -84,6 +101,11 @@ export function createUpstreamClient({
84
101
  "X-Title": "Hermes Agent",
85
102
  };
86
103
  }
104
+ // 显式 public 回退(anonFirst 时去掉 hermes 头)
105
+ if (anonFirst) {
106
+ const { "HTTP-Referer": _a, "X-Title": _b, ...rest } = base;
107
+ return { ...rest, "Authorization": `Bearer ${authToken}` };
108
+ }
87
109
  return base;
88
110
  }
89
111
 
@@ -238,8 +260,8 @@ export function createUpstreamClient({
238
260
  waitMs += entry.delayMs;
239
261
  continue;
240
262
  }
241
- // 额外额度探测:public 429 且为 free 模型时,用空头(hermes 方式)每秒重试 3
242
- if (result.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
263
+ // 额外额度探测:仅当非 anon 优先时,public 429 且为 free 模型时,用空头(hermes 方式)每秒重试 3 次;anon 优先时首发即高额度,无需再 public 撞墙
264
+ if (!anonFirst && result.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
243
265
  const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
244
266
  const anonDelay = envInt("MSLXDFF_FREE_ANON_DELAY_MS", 1000);
245
267
  let anonResult = null;
@@ -349,7 +371,7 @@ export function createUpstreamClient({
349
371
  }
350
372
  }
351
373
 
352
- async function attemptOnce(url, body, { anonymous = false } = {}) {
374
+ async function attemptOnce(url, body, { anonymous = anonFirst } = {}) {
353
375
  const controller = new AbortController();
354
376
  const timer = setTimeout(() =>
355
377
  controller.abort(new Error(`upstream timed out after ${connectTimeoutMs}ms`)),