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 +1 -1
- package/src/autostart.js +44 -8
- package/src/bench/report.js +81 -0
- package/src/bench/via-probe.js +87 -0
- package/src/bench/via.js +126 -0
- package/src/cli/commands/provider/bench.js +136 -71
- package/src/runtime/auto-update.js +84 -0
- package/src/runtime/bootstrap.js +62 -79
- package/src/upstream.js +31 -9
package/package.json
CHANGED
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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 :
|
|
185
|
-
for (let i = 0; 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(
|
|
188
|
-
|
|
189
|
-
|
|
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"]);
|
package/src/bench/report.js
CHANGED
|
@@ -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
|
+
}
|
package/src/bench/via.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -191,7 +191,46 @@ export async function startDaemonMain(VERSION) {
|
|
|
191
191
|
process.on("SIGTERM", restore2);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
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
|
|
333
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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 =
|
|
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`)),
|