mslxdff 0.1.78 → 0.1.80
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/bench/report.js +98 -0
- package/src/bench/via-probe.js +142 -0
- package/src/bench/via.js +130 -0
- package/src/chat/gateway.js +7 -3
- package/src/chat/orchestrator.js +17 -8
- package/src/chat/repl.js +12 -4
- package/src/chat/stats.js +25 -2
- package/src/cli/commands/provider/bench.js +159 -71
- package/src/providers/cline/headers.js +2 -2
- package/src/routes/index.js +7 -0
- package/src/routes/relay.js +59 -0
- package/src/upstream.js +31 -9
package/package.json
CHANGED
package/src/bench/report.js
CHANGED
|
@@ -23,6 +23,104 @@ function pad(s, n, align = "left") {
|
|
|
23
23
|
return str + " ".repeat(d);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function shortPeerLabel(p) {
|
|
27
|
+
const raw = String(p?.name || p?.id || p?.url || String(p || "")).trim();
|
|
28
|
+
if (!raw) return "peer";
|
|
29
|
+
// 优先用 name;若是 url 则取 host:port 的尾段
|
|
30
|
+
if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("relay://")) {
|
|
31
|
+
try { const u = new URL(raw); const host = u.hostname; const port = u.port ? `:${u.port}` : ""; if (host) return `${host}${port}`; } catch {}
|
|
32
|
+
return raw.slice(-12);
|
|
33
|
+
}
|
|
34
|
+
// name 可能是 url 字符串(如 "http://141.98..." 存于 name 字段)
|
|
35
|
+
if (raw.includes("://")) {
|
|
36
|
+
try { const u = new URL(raw); return u.hostname + (u.port ? `:${u.port}` : ""); } catch {}
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
export function formatViaReport(results, { peers = [], meta = {}, json = false } = {}) {
|
|
41
|
+
const peerIds = (peers || []).map((p) => shortPeerLabel(p));
|
|
42
|
+
const samples = meta.samples ?? 1;
|
|
43
|
+
const timeout = meta.timeout ?? 30000;
|
|
44
|
+
const includeOpencode = Boolean(meta.includeOpencode);
|
|
45
|
+
const opencodeTag = includeOpencode ? "opencode=included" : "opencode=skipped";
|
|
46
|
+
const at = meta.at || new Date().toISOString();
|
|
47
|
+
// build json shape
|
|
48
|
+
const jsonObj = {
|
|
49
|
+
meta: { at, samples, timeout, includeOpencode, peers: peerIds, opencodeSkipped: !includeOpencode, ...meta, peers: peerIds },
|
|
50
|
+
results: (results || []).map((r) => ({
|
|
51
|
+
provider: r.provider,
|
|
52
|
+
model: r.model || r.id,
|
|
53
|
+
direct: r.direct ? { ttfb: r.direct.ttfbMs, total: r.direct.totalMs, ok: r.direct.ok, label: r.direct.label, error: r.direct.error } : null,
|
|
54
|
+
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 }])),
|
|
55
|
+
best: r.best,
|
|
56
|
+
deltaMs: r.deltaMs,
|
|
57
|
+
opencodeSkipped: r.opencodeSkipped,
|
|
58
|
+
})),
|
|
59
|
+
advice: (() => {
|
|
60
|
+
const viaBest = (results || []).find((r) => r.best?.startsWith("via:"));
|
|
61
|
+
if (viaBest) return `${viaBest.provider}/${viaBest.model} 经 ${viaBest.best.slice(4)} 最快`;
|
|
62
|
+
if ((results || []).length) return `${results[0].provider} 走 direct 即可`;
|
|
63
|
+
return "无数据";
|
|
64
|
+
})(),
|
|
65
|
+
};
|
|
66
|
+
if (json) {
|
|
67
|
+
return { text: JSON.stringify(jsonObj, null, 2), json: jsonObj };
|
|
68
|
+
}
|
|
69
|
+
const lines = [];
|
|
70
|
+
lines.push(`bench-via: direct vs via peers (samples=${samples}, timeout=${timeout / 1000}s, ${opencodeTag}) peers=${peerIds.join(",") || "(none)"}`);
|
|
71
|
+
lines.push("");
|
|
72
|
+
// 表头:直连 + 每个组员一列(短化为 host:port 或 name),兼容旧测试 via B 断言与用户“直连/组员B”心智
|
|
73
|
+
const colW = 18;
|
|
74
|
+
const header = `${pad("Provider", 12)} ${pad("Model", 24)} ${pad("direct", colW, "right")} ${peerIds.map((id) => pad(`via ${id}`, colW, "right")).join(" ")} ${pad("best", 14)}`;
|
|
75
|
+
lines.push(header);
|
|
76
|
+
lines.push("─".repeat(header.length));
|
|
77
|
+
for (const r of results || []) {
|
|
78
|
+
const provider = pad(r.provider || "", 12);
|
|
79
|
+
const model = pad(r.model || r.id || "", 24);
|
|
80
|
+
const directOk = r.direct?.ok;
|
|
81
|
+
const directTxt = directOk ? `${r.direct.ttfbMs ?? "—"}ms` : (r.direct?.label || "—");
|
|
82
|
+
// determine best ttfb for ★
|
|
83
|
+
const all = [];
|
|
84
|
+
if (r.direct?.ok) all.push({ key: "direct", ttfb: r.direct.ttfbMs ?? r.direct.totalMs });
|
|
85
|
+
for (const pid of peerIds) {
|
|
86
|
+
const v = r.via?.[pid];
|
|
87
|
+
if (v?.ok) all.push({ key: `via:${pid}`, ttfb: v.ttfbMs ?? v.totalMs });
|
|
88
|
+
}
|
|
89
|
+
let bestKey = r.best;
|
|
90
|
+
if (!bestKey && all.length) bestKey = all.sort((a, b) => a.ttfb - b.ttfb)[0].key;
|
|
91
|
+
const isDirectBest = bestKey === "direct";
|
|
92
|
+
const directCell = pad(`${directTxt}${isDirectBest ? "★" : ""}`, colW, "right");
|
|
93
|
+
const viaCells = peerIds.map((pid) => {
|
|
94
|
+
const v = r.via?.[pid];
|
|
95
|
+
if (!v) return pad("—", colW, "right");
|
|
96
|
+
if (v.ok) {
|
|
97
|
+
const txt = `${v.ttfbMs ?? v.totalMs ?? "—"}ms`;
|
|
98
|
+
const star = bestKey === `via:${pid}` ? "★" : "";
|
|
99
|
+
return pad(`${txt}${star}`, colW, "right");
|
|
100
|
+
}
|
|
101
|
+
// 失败也展示延迟(先测延迟):如 42ms 鉴权失败,说明网络可达但该组员未配此供应商
|
|
102
|
+
const ms = v.ttfbMs != null ? `${v.ttfbMs}ms ` : "";
|
|
103
|
+
const label = v.label || v.error || "offline";
|
|
104
|
+
const short = label.includes("离线") ? "offline" : label.slice(0, 8);
|
|
105
|
+
return pad(`${ms}— ${short}`, colW, "right");
|
|
106
|
+
}).join(" ");
|
|
107
|
+
let bestTxt = r.best || "direct";
|
|
108
|
+
if (r.deltaMs != null && r.best?.startsWith("via:")) {
|
|
109
|
+
const pct = r.direct?.ttfbMs ? Math.round((r.deltaMs / r.direct.ttfbMs) * 100) : 0;
|
|
110
|
+
bestTxt = `${r.best} ${pct}%`;
|
|
111
|
+
}
|
|
112
|
+
lines.push(`${provider} ${model} ${directCell} ${viaCells} ${pad(bestTxt, 14)}`);
|
|
113
|
+
}
|
|
114
|
+
lines.push("─".repeat(header.length));
|
|
115
|
+
const viaBestExample = (results || []).find((r) => r.best?.startsWith("via:"));
|
|
116
|
+
if (viaBestExample) lines.push(`建议:A 经 ${viaBestExample.best.slice(4)} 打 ${viaBestExample.provider} 最快;其余走 direct。`);
|
|
117
|
+
else if ((results || []).length) lines.push(`建议:${results[0].provider} 走 direct 即可。`);
|
|
118
|
+
else lines.push("建议:无数据");
|
|
119
|
+
lines.push(`提示:via 已跳过 opencode(省额度),需对比 opencode 请加 --include-opencode`);
|
|
120
|
+
lines.push(`* via 单样本,仅作参考,多次 --samples 2 取均值更稳`);
|
|
121
|
+
return { text: lines.join("\n"), json: jsonObj };
|
|
122
|
+
}
|
|
123
|
+
|
|
26
124
|
export function formatReport(results, { json = false } = {}) {
|
|
27
125
|
const sorted = sortResults(results);
|
|
28
126
|
const winner = sorted.find((r) => r.ok) || null;
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
shareKeys,
|
|
40
|
+
shareKeysHeader,
|
|
41
|
+
relayTarget,
|
|
42
|
+
relayHeaders,
|
|
43
|
+
relayBody,
|
|
44
|
+
targetUrl,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const started = clock();
|
|
47
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
|
|
48
|
+
const base = String(peerUrl || "").replace(/\/+$/, "");
|
|
49
|
+
if (!base) return { ok: false, label: "配置错误", error: "missing peerUrl", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
50
|
+
// 纯中继模式:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游(不查 B 本地 providerConfigs)
|
|
51
|
+
const rt = String(relayTarget || targetUrl || "").trim();
|
|
52
|
+
if (rt) {
|
|
53
|
+
const rh = relayHeaders && typeof relayHeaders === "object" ? relayHeaders : {};
|
|
54
|
+
const rb = relayBody !== undefined ? relayBody : null;
|
|
55
|
+
const relayUrl = joinUrl(base, "/v1/relay");
|
|
56
|
+
const relayHeadersOut = { "Content-Type": "application/json", Accept: "application/json" };
|
|
57
|
+
if (token) relayHeadersOut.Authorization = `Bearer ${token}`;
|
|
58
|
+
const payload = { targetUrl: rt, method: "POST", headers: rh, body: rb };
|
|
59
|
+
let ttfbMs = null;
|
|
60
|
+
try {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
63
|
+
const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
64
|
+
let res;
|
|
65
|
+
try { res = await fetchImpl(relayUrl, { method: "POST", headers: relayHeadersOut, body: JSON.stringify(payload), signal: controller.signal }); } finally { clearTimeout(timer); }
|
|
66
|
+
ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
|
|
67
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
let txt = ""; try { txt = await res.text(); } catch {}
|
|
70
|
+
const cls = classifyError(res.status, txt);
|
|
71
|
+
const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
|
|
72
|
+
return { ok: false, status: res.status, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
73
|
+
}
|
|
74
|
+
let txt = ""; let j = {};
|
|
75
|
+
try { txt = await res.text(); j = JSON.parse(txt); } catch { j = {}; }
|
|
76
|
+
const relayStatus = res.headers.get("x-mslxdff-relay-status") ? Number(res.headers.get("x-mslxdff-relay-status")) : res.status;
|
|
77
|
+
if (relayStatus >= 400) {
|
|
78
|
+
const cls = classifyError(relayStatus, txt);
|
|
79
|
+
const msg = extractInnerMessage(txt) || `HTTP ${relayStatus}`;
|
|
80
|
+
return { ok: false, status: relayStatus, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
81
|
+
}
|
|
82
|
+
const usage = extractUsageFromJson(j);
|
|
83
|
+
const content = j?.choices?.[0]?.message?.content || j?.choices?.[0]?.text || txt || "";
|
|
84
|
+
const chars = typeof content === "string" ? content.length : 0;
|
|
85
|
+
const pt = usage?.prompt_tokens ?? null;
|
|
86
|
+
const ct = usage?.completion_tokens ?? null;
|
|
87
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens: pt, completionTokens: ct, chars });
|
|
88
|
+
const totalTokens = usage?.total_tokens ?? (pt !== null && ct !== null ? pt + ct : null);
|
|
89
|
+
return { ok: true, status: relayStatus, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: pt, completion: ct, total: totalTokens }, chars };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
92
|
+
const msg = e?.message || String(e);
|
|
93
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
94
|
+
return { ok: false, label: isTimeout ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!model) return { ok: false, label: "配置错误", error: "missing model", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
98
|
+
let rawModel = String(model).trim();
|
|
99
|
+
if (providerId && rawModel.startsWith(`${providerId}/`)) rawModel = rawModel.slice(providerId.length + 1);
|
|
100
|
+
const url = joinUrl(base, "/v1/chat/completions");
|
|
101
|
+
const body = { model: rawModel, stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
102
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
|
103
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
104
|
+
const sk = shareKeysHeader || shareKeys;
|
|
105
|
+
if (sk) headers["x-mslxdff-share-keys"] = String(sk);
|
|
106
|
+
let ttfbMs = null;
|
|
107
|
+
try {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
110
|
+
const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: controller.signal });
|
|
114
|
+
} finally { clearTimeout(timer); }
|
|
115
|
+
ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
|
|
116
|
+
if (res instanceof Error) throw res;
|
|
117
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
let txt = "";
|
|
120
|
+
try { txt = await res.text(); } catch {}
|
|
121
|
+
const cls = classifyError(res.status, txt);
|
|
122
|
+
const msg = extractInnerMessage(txt) || `HTTP ${res.status}`;
|
|
123
|
+
return { ok: false, status: res.status, label: cls.label, error: msg, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
124
|
+
}
|
|
125
|
+
let json = {};
|
|
126
|
+
let txt = "";
|
|
127
|
+
try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
|
|
128
|
+
const usage = extractUsageFromJson(json);
|
|
129
|
+
const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
|
|
130
|
+
const chars = typeof content === "string" ? content.length : 0;
|
|
131
|
+
const promptTokens = usage?.prompt_tokens ?? null;
|
|
132
|
+
const completionTokens = usage?.completion_tokens ?? null;
|
|
133
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
|
|
134
|
+
const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
|
|
135
|
+
return { ok: true, status: res.status, label: "成功", ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars };
|
|
136
|
+
} catch (e) {
|
|
137
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
138
|
+
const msg = e?.message || String(e);
|
|
139
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
140
|
+
return { ok: false, label: isTimeout ? "超时" : "网络错误", error: msg.slice(0, 300), ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/bench/via.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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 raw = String(peer.name || peer.id || peer.url || "peer");
|
|
103
|
+
let peerId = raw;
|
|
104
|
+
// 仅当 raw 本身就是 url 时才短化;显式 name/id 保持原样,避免 B → b:8989
|
|
105
|
+
if (!peer.name && !peer.id && raw.includes("://")) { try { const u = new URL(raw); peerId = u.hostname + (u.port ? `:${u.port}` : ""); } catch { peerId = raw.slice(-16); } }
|
|
106
|
+
const peerToken = peer.token || token || "";
|
|
107
|
+
try {
|
|
108
|
+
const r = await viaProbeFn({ peerUrl: peer.url, token: peerToken, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
|
|
109
|
+
via[peerId] = r;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
via[peerId] = { ok: false, label: "网络错误", error: String(e?.message || e), ttfbMs: null, totalMs: 0 };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// best
|
|
115
|
+
let best = "direct";
|
|
116
|
+
let bestTtfb = direct?.ok ? (direct.ttfbMs ?? direct.totalMs) : Infinity;
|
|
117
|
+
let deltaMs = null;
|
|
118
|
+
for (const [pid, rv] of Object.entries(via)) {
|
|
119
|
+
if (!rv.ok) continue;
|
|
120
|
+
const t = rv.ttfbMs ?? rv.totalMs;
|
|
121
|
+
if (t < bestTtfb) { bestTtfb = t; best = `via:${pid}`; }
|
|
122
|
+
}
|
|
123
|
+
if (best.startsWith("via:")) {
|
|
124
|
+
const d = direct?.ttfbMs ?? direct?.totalMs ?? 0;
|
|
125
|
+
deltaMs = bestTtfb - d;
|
|
126
|
+
}
|
|
127
|
+
results.push({ provider, model, direct, via, best, deltaMs, opencodeSkipped: !includeOpencode });
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}
|
package/src/chat/gateway.js
CHANGED
|
@@ -136,9 +136,13 @@ export function createGatewayClient({
|
|
|
136
136
|
}
|
|
137
137
|
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: rawModel, provider, viaGateway: true };
|
|
138
138
|
} catch (err) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
const raw = String(err?.message || err);
|
|
140
|
+
const msg = raw.slice(0, 800);
|
|
141
|
+
const isConnRefused = /ECONNREFUSED|Failed to fetch|fetch failed|connect ECONNREFUSED|ECONNRESET|EADDRNOTAVAIL/i.test(raw);
|
|
142
|
+
if (isConnRefused) {
|
|
143
|
+
const friendly = `本地服务没有启动无法使用auto模式,请mslxdff -d 启动(本地网关 http://127.0.0.1:${port} 拒绝连接)— 3ms 内失败说明未触达任何模型(非模型额度问题);若已改端口请用 mslxdff -port N 或 MSLXDFF_PORT=${port} 保持一致`;
|
|
144
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${Math.round(performance.now() - t0)}ms · ${friendly} · ${msg.slice(0, 80)}\x1b[0m`);
|
|
145
|
+
return { ok: false, error: friendly, status: 502, code: "GATEWAY_NOT_RUNNING", port, raw: msg };
|
|
142
146
|
}
|
|
143
147
|
return { ok: false, error: `gateway ${msg}`, status: 502 };
|
|
144
148
|
}
|
package/src/chat/orchestrator.js
CHANGED
|
@@ -64,7 +64,7 @@ export function createOrchestrator({
|
|
|
64
64
|
let first;
|
|
65
65
|
let firstMs = 0;
|
|
66
66
|
if (firstCooling) {
|
|
67
|
-
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED}
|
|
67
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却 10min)· 额度用完,准备走网关 auto(将尝试其他可用模型)\x1b[0m`);
|
|
68
68
|
first = { ok: false, error: "skip cooling", status: 429 };
|
|
69
69
|
} else {
|
|
70
70
|
const t = perf.now();
|
|
@@ -81,13 +81,16 @@ export function createOrchestrator({
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
if (firstCooling) {
|
|
84
|
-
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS / 60000}min)· 直接走网关 auto
|
|
85
|
-
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却,直接走网关 auto(:8989)\x1b[0m`);
|
|
84
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 冷却中(${CHAT_COOLDOWN_MS / 60000}min)· 直接走网关 auto(:8989),按 auto 择优尝试其他可用模型,跳过 ${CHAT_FALLBACK} 直连\x1b[0m`);
|
|
86
85
|
const t2 = TRACE ? perf.now() : 0;
|
|
87
86
|
const third = await _gateway(opts);
|
|
88
87
|
if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
89
88
|
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip big-pickle (mimo cooling)", viaGateway: true };
|
|
90
|
-
|
|
89
|
+
const isGwDown = third.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(third.error || ""));
|
|
90
|
+
if (isGwDown) {
|
|
91
|
+
return { ok: false, error: `${CHAT_PREFERRED} 冷却跳过(额度用完 10min);${third.error} —— 本次 auto 未能尝试任何其他模型,因网关未就绪(3ms 失败即证明未触达上游,请先启动网关再重试)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, error: `${CHAT_PREFERRED} 冷却跳过(额度用完);网关 auto 已尝试其他可用模型但均失败:${third.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: third.status || 429 };
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
|
|
@@ -102,12 +105,14 @@ export function createOrchestrator({
|
|
|
102
105
|
};
|
|
103
106
|
|
|
104
107
|
if (secondCooling) {
|
|
105
|
-
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto
|
|
108
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto(将尝试其他可用模型)\x1b[0m`);
|
|
106
109
|
const t2 = perf.now();
|
|
107
110
|
const third = await doGateway();
|
|
108
111
|
if (TRACE) console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${Math.round(perf.now() - t2)}ms · 总 ${Math.round(perf.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
109
112
|
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: "skip cooling", viaGateway: true };
|
|
110
|
-
|
|
113
|
+
const isGwDown2 = third.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(third.error || ""));
|
|
114
|
+
if (isGwDown2) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 冷却跳过;${third.error} —— auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
|
|
115
|
+
return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 冷却跳过;网关 auto 已尝试其他模型但均失败:${third.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: third.status || first.status };
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 失败,${CHAT_FALLBACK} + gateway 对冲中(${HEDGE_MS}ms)\x1b[0m`);
|
|
@@ -167,7 +172,9 @@ export function createOrchestrator({
|
|
|
167
172
|
secondRes = sRes; gatewayRes = gRes;
|
|
168
173
|
if (sRes?.ok) return { ...sRes, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
169
174
|
if (gRes?.ok) return { ...gRes, model: gRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: sRes?.error, viaGateway: true };
|
|
170
|
-
|
|
175
|
+
const gwDown0 = gRes?.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(gRes?.error || ""));
|
|
176
|
+
if (gwDown0) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${sRes?.error};${gRes?.error} —— gateway 侧 auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
|
|
177
|
+
return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${sRes?.error};网关 auto 已尝试其他模型但均失败:${gRes?.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: gRes?.status || sRes?.status || first.status };
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
const raced = await raceFirstOk();
|
|
@@ -183,7 +190,9 @@ export function createOrchestrator({
|
|
|
183
190
|
try { gatewayRes = await (gatewayPromise || doGateway()); } catch (e) { gatewayRes = { ok: false, error: String(e), status: 502 }; }
|
|
184
191
|
}
|
|
185
192
|
if (gatewayRes?.ok) return { ...gatewayRes, model: gatewayRes.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: secondRes?.error, viaGateway: true };
|
|
186
|
-
|
|
193
|
+
const gwDown = gatewayRes?.code === "GATEWAY_NOT_RUNNING" || /本地(网关未运行|服务没有启动)/.test(String(gatewayRes?.error || ""));
|
|
194
|
+
if (gwDown) return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${secondRes?.error};${gatewayRes?.error} —— gateway 侧 auto 未能尝试其他模型(网关未就绪)`, status: 502, code: "GATEWAY_NOT_RUNNING" };
|
|
195
|
+
return { ok: false, error: `${CHAT_PREFERRED} 失败:${first.error};${CHAT_FALLBACK} 失败:${secondRes?.error};网关 auto 已尝试其他模型但均失败:${gatewayRes?.error}(总 ${Math.round(perf.now() - t0)}ms)`, status: gatewayRes?.status || secondRes?.status || first.status };
|
|
187
196
|
}
|
|
188
197
|
|
|
189
198
|
async function summarizeHistory(messages) {
|
package/src/chat/repl.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getToolDefs, execCommand, readFileTool, curlTool } from "./tools.js";
|
|
|
6
6
|
import { chatWithFallback, summarizeHistory } from "./upstream.js";
|
|
7
7
|
import { loadHistory, saveHistory, clearHistory, histPath, estimateChars, needsCompress } from "./store.js";
|
|
8
8
|
import { CHAT_KEEP_RECENT, CHAT_MAX_TOOL_LOOPS, CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
|
|
9
|
-
import { formatBannerLines, formatStatsDetail, collectStats } from "./stats.js";
|
|
9
|
+
import { formatBannerLines, formatStatsDetail, collectStats, probeGateway } from "./stats.js";
|
|
10
10
|
import { createSpinner } from "./spinner.js";
|
|
11
11
|
import { normalizeFullId } from "../providers/model-id.js";
|
|
12
12
|
|
|
@@ -18,9 +18,17 @@ const SLASH_HELP = `自然语言直接说,斜杠快捷:
|
|
|
18
18
|
/exit 退出
|
|
19
19
|
示例:设置 hy3 为默认模型 / 查看组列表 / 看最近20条日志 / 读一下 src/logs.js`;
|
|
20
20
|
|
|
21
|
-
function printBanner() {
|
|
22
|
-
|
|
21
|
+
async function printBanner() {
|
|
22
|
+
let probe = null;
|
|
23
|
+
try {
|
|
24
|
+
const s = collectStats();
|
|
25
|
+
probe = await probeGateway(s.port, 800);
|
|
26
|
+
} catch {}
|
|
27
|
+
const { lines } = formatBannerLines(probe);
|
|
23
28
|
for (const l of lines) console.log(l);
|
|
29
|
+
if (probe && !probe.alive) {
|
|
30
|
+
console.log(`\x1b[31m本地服务没有启动无法使用auto模式,请mslxdff -d 启动\x1b[0m`);
|
|
31
|
+
}
|
|
24
32
|
console.log(`\x1b[90m输入自然语言即可执行;/help 帮助,/stats 看网关统计,/exit 退出\x1b[0m`);
|
|
25
33
|
console.log(`\x1b[90m历史:${histPath()} · 仅拦截 -uninstall · 数据来自网关 -d,非本会话计数\x1b[0m`);
|
|
26
34
|
}
|
|
@@ -283,7 +291,7 @@ export async function startRepl({ singleShot } = {}) {
|
|
|
283
291
|
saveHistory(messages.slice(1));
|
|
284
292
|
return;
|
|
285
293
|
}
|
|
286
|
-
printBanner();
|
|
294
|
+
await printBanner();
|
|
287
295
|
const rl = readline.createInterface({ input: stdin, output: stdout, prompt: `\x1b[36m${CHAT_PREFERRED.split("-")[0]}>\x1b[0m ` });
|
|
288
296
|
rl.prompt();
|
|
289
297
|
for await (const line of rl) {
|
package/src/chat/stats.js
CHANGED
|
@@ -141,17 +141,40 @@ export function collectStats() {
|
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
export function
|
|
144
|
+
export async function probeGateway(port, timeoutMs = 800) {
|
|
145
|
+
const url = `http://127.0.0.1:${port}/health`;
|
|
146
|
+
const ctrl = new AbortController();
|
|
147
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
148
|
+
try {
|
|
149
|
+
const r = await fetch(url, { signal: ctrl.signal });
|
|
150
|
+
clearTimeout(t);
|
|
151
|
+
return { alive: r.ok, status: r.status, ms: 0 };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
clearTimeout(t);
|
|
154
|
+
const msg = String(e?.message || e);
|
|
155
|
+
const isRefused = /ECONNREFUSED|Failed to fetch|fetch failed|ECONNRESET|abort/i.test(msg);
|
|
156
|
+
return { alive: false, status: 0, error: msg, refused: isRefused };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function formatBannerLines(healthProbe = null) {
|
|
145
161
|
const s = collectStats();
|
|
146
162
|
const dim = "\x1b[90m";
|
|
147
163
|
const rst = "\x1b[0m";
|
|
148
164
|
const cyan = "\x1b[36m";
|
|
149
165
|
const yellow = "\x1b[33m";
|
|
150
166
|
const green = "\x1b[32m";
|
|
167
|
+
const red = "\x1b[31m";
|
|
168
|
+
const bgRed = "\x1b[41m\x1b[37m";
|
|
151
169
|
const lines = [];
|
|
152
170
|
lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
|
|
171
|
+
if (healthProbe && !healthProbe.alive) {
|
|
172
|
+
lines.push(`${red}│ ✗ 本地服务没有启动无法使用auto模式,请mslxdff -d 启动 · ${s.healthUrl} 拒绝连接(${healthProbe.error?.slice(0,60) || "ECONNREFUSED"})${rst}`);
|
|
173
|
+
lines.push(`${red}│ → 先在另一终端执行: mslxdff -d (或 mslxdff) 启动后回此窗口重试,期间仅 mimo/big-pickle 直连可用,auto 不可用${rst}`);
|
|
174
|
+
}
|
|
153
175
|
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
|
|
154
|
-
|
|
176
|
+
const gwAliveTag = healthProbe ? (healthProbe.alive ? `${green}● 运行中${rst}` : `${red}● 未运行${rst}`) : `${dim}…检测中${rst}`;
|
|
177
|
+
lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} ${gwAliveTag} · ${dim}${s.endpointUrl}${rst}`);
|
|
155
178
|
const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
|
|
156
179
|
const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
|
|
157
180
|
const gateTtfb = s.gatewayStat?.avgTtfbMs ?? s.gatewayStat?.emaTtfbMs ?? s.gatewayLat?.emaMs;
|
|
@@ -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,164 @@ 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 { joinUrl: _joinUrl } = await import("../../../providers/base.js");
|
|
191
|
+
const viaProbeFn = async (args) => {
|
|
192
|
+
// 纯中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游(不查 B 本地 providerConfigs)
|
|
193
|
+
const { peerUrl, providerId, model } = args;
|
|
194
|
+
const p = providerId || pid;
|
|
195
|
+
const idx = models.findIndex((x) => x.model === model);
|
|
196
|
+
const kIdx = idx >= 0 ? idx % (keys.length || 1) : 0;
|
|
197
|
+
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
198
|
+
const key = keys[kIdx] || keys[0] || "";
|
|
199
|
+
const auth = auths[aIdx] || auths[0] || null;
|
|
200
|
+
const cKeys = keys.filter((k) => isRefreshToken(k));
|
|
201
|
+
if (cKeys.length) {
|
|
202
|
+
// cline refreshToken 需特殊流,暂走旧的 peer chat(peer 需自有 cline 配置)
|
|
203
|
+
return viaProbe({ ...args, token, fetchImpl });
|
|
204
|
+
}
|
|
205
|
+
const rawModel = String(model).startsWith(`${p}/`) ? String(model).slice(p.length + 1) : String(model);
|
|
206
|
+
const targetUrl = p === "opencode" ? `${process.env.UPSTREAM_BASE_URL || "https://opencode.ai"}/zen/v1/chat/completions` : `${String(baseUrl).replace(/\/+$/, "")}${chatPath}`;
|
|
207
|
+
const headers = buildHeadersForProvider(p, key, auth);
|
|
208
|
+
const body = { model: rawModel, stream: false, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
|
|
209
|
+
// 用对端 peer.token 做 relay 鉴权(B 只认自己的 Bearer)
|
|
210
|
+
const peer = peers.find((pe) => pe.url === peerUrl || (pe.name || pe.id) === peerUrl);
|
|
211
|
+
const peerToken = peer?.token || token;
|
|
212
|
+
return viaProbe({ peerUrl, token: peerToken, relayTarget: targetUrl, relayHeaders: headers, relayBody: body, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
213
|
+
};
|
|
214
|
+
const part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs });
|
|
215
|
+
allResults.push(...part);
|
|
216
|
+
if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成`);
|
|
217
|
+
}
|
|
218
|
+
const meta = { at: new Date().toISOString(), samples: opts.samples, timeout: opts.timeoutMs, includeOpencode, peers: peers.map((p) => p.id), opencodeSkipped: !includeOpencode };
|
|
219
|
+
const report = formatViaReport(allResults, { peers, meta, json: opts.json });
|
|
220
|
+
if (opts.json) console.log(report.text);
|
|
221
|
+
else console.log("\n" + report.text);
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
|
|
104
225
|
export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
105
|
-
const
|
|
226
|
+
const _restArr = rest || [];
|
|
227
|
+
const _hasVia = _restArr.includes("--via") || _restArr.includes("--bench-via");
|
|
228
|
+
const _isBenchViaAll = (String(id) === "bench" || String(id) === "all") && _hasVia;
|
|
229
|
+
const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test" || _isBenchViaAll;
|
|
106
230
|
if (!isBench) return false;
|
|
231
|
+
const opts = parseBenchArgs(_restArr);
|
|
232
|
+
// via branch
|
|
233
|
+
if (opts.via) {
|
|
234
|
+
const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
|
|
235
|
+
const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
|
|
236
|
+
const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
|
|
237
|
+
const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
|
|
238
|
+
const viaPid = _isBenchViaAll ? "bench" : String(id || "").trim();
|
|
239
|
+
if (!viaPid) { console.error("usage: mslxdff -provider <id> bench --via [--json] [--include-opencode]"); process.exit(1); }
|
|
240
|
+
await handleVia({ providerId: viaPid, opts, fetchImpl: deps.fetchImpl || globalThis.fetch, loadConfigs, loadKeys, loadAllowed, loadBaseUrl });
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
107
243
|
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
108
244
|
const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
|
|
109
245
|
const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
|
|
110
246
|
const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
|
|
111
247
|
const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
|
|
112
248
|
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
249
|
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 || []);
|
|
250
|
+
if (!providerId) { console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]"); process.exit(1); }
|
|
121
251
|
const configs = loadConfigs();
|
|
122
252
|
const cfg = configs[providerId] || {};
|
|
123
253
|
const keys = loadKeys(providerId) || [];
|
|
@@ -126,38 +256,19 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
126
256
|
const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
|
|
127
257
|
let auths = [];
|
|
128
258
|
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,仅探活模型列表并提示
|
|
259
|
+
if (!baseUrl) { console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`); process.exit(1); }
|
|
260
|
+
if (!keys.length) { console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`); process.exit(1); }
|
|
138
261
|
if (!allowed.length) {
|
|
139
262
|
const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
|
|
140
263
|
console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
|
|
141
264
|
console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
|
|
142
265
|
const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
|
|
143
266
|
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
|
-
}
|
|
267
|
+
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
268
|
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
|
-
}
|
|
269
|
+
if (!list.length) { console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models"); if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2)); process.exit(0); }
|
|
157
270
|
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
|
-
}
|
|
271
|
+
for (const m of list.slice(0, 30)) console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
|
|
161
272
|
if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
|
|
162
273
|
console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
|
|
163
274
|
console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
|
|
@@ -165,14 +276,10 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
165
276
|
if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
|
|
166
277
|
process.exit(0);
|
|
167
278
|
}
|
|
168
|
-
|
|
169
|
-
// 有勾选 → 逐个测
|
|
170
279
|
const log = (s) => (opts.json ? console.error(s) : console.log(s));
|
|
171
280
|
log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
|
|
172
281
|
if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
|
|
173
282
|
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
283
|
const rtKeys = keys.filter((k) => isRefreshToken(k));
|
|
177
284
|
const normBase = String(baseUrl).replace(/\/+$/, "");
|
|
178
285
|
const clineChatBase = normBase.endsWith("/api/v1") ? normBase.slice(0, -7) : normBase;
|
|
@@ -181,46 +288,27 @@ export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
|
181
288
|
const raw = allowed[i];
|
|
182
289
|
const model = String(raw || "").trim();
|
|
183
290
|
if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
|
|
184
|
-
// 轮询 key/auth:按索引取,超长循环
|
|
185
291
|
const kIdx = i % (keys.length || 1);
|
|
186
292
|
const aIdx = Math.min(kIdx, Math.max(auths.length - 1, 0));
|
|
187
293
|
const key = keys[kIdx];
|
|
188
294
|
const auth = auths[aIdx] || auths[0] || null;
|
|
189
|
-
|
|
190
295
|
if (rtKeys.length) {
|
|
191
296
|
const rt = rtKeys[kIdx % rtKeys.length];
|
|
192
297
|
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
|
-
}
|
|
298
|
+
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
299
|
const r = await clineBenchOne({ baseUrl: clineChatBase, model, accessToken: at, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
200
300
|
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
|
-
}
|
|
301
|
+
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
302
|
continue;
|
|
206
303
|
}
|
|
207
|
-
|
|
208
304
|
const headers = buildHeadersForProvider(providerId, key, auth);
|
|
209
305
|
const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
210
306
|
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
|
-
}
|
|
307
|
+
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
308
|
}
|
|
216
309
|
const report = formatReport(results, { json: opts.json });
|
|
217
310
|
console.log("\n" + report.text);
|
|
218
|
-
if (opts.json) {
|
|
219
|
-
// json 已在 text 中输出一次(report.text 是 JSON),无需重复
|
|
220
|
-
}
|
|
221
311
|
const failed = results.filter((r) => !r.ok).length;
|
|
222
|
-
if (failed && !opts.json) {
|
|
223
|
-
console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
|
|
224
|
-
}
|
|
312
|
+
if (failed && !opts.json) console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
|
|
225
313
|
process.exit(failed ? 2 : 0);
|
|
226
314
|
}
|
|
@@ -23,8 +23,8 @@ export function clineHeaders(sessionId, token) {
|
|
|
23
23
|
export function isRefreshToken(key) {
|
|
24
24
|
const s = String(key || "").trim();
|
|
25
25
|
if (!s) return false;
|
|
26
|
-
// sk_ 形态直接视为旧直连 key
|
|
27
|
-
if (s.startsWith("sk_")) return false;
|
|
26
|
+
// sk_ / sk- 形态直接视为旧直连 key(OpenAI 兼容网关),不走 refresh 链
|
|
27
|
+
if (s.startsWith("sk_") || s.startsWith("sk-")) return false;
|
|
28
28
|
// refreshToken 通常为 JWT 或长随机串,长度 > 20 且含 . 或 -
|
|
29
29
|
if (s.length > 20) return true;
|
|
30
30
|
return false;
|
package/src/routes/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chatHandler } from "./chat.js";
|
|
|
5
5
|
import { joinHandler, leaveHandler } from "./groups.js";
|
|
6
6
|
import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
|
|
7
7
|
import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
|
|
8
|
+
import { relayHandler } from "./relay.js";
|
|
8
9
|
|
|
9
10
|
export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus, plugins }) {
|
|
10
11
|
return async function router(req, res) {
|
|
@@ -78,6 +79,12 @@ const ROUTES = [
|
|
|
78
79
|
requiresAuth: true,
|
|
79
80
|
handler: forwardHandler,
|
|
80
81
|
},
|
|
82
|
+
{
|
|
83
|
+
method: "POST",
|
|
84
|
+
path: "/v1/relay",
|
|
85
|
+
requiresAuth: true,
|
|
86
|
+
handler: relayHandler,
|
|
87
|
+
},
|
|
81
88
|
{
|
|
82
89
|
method: "GET",
|
|
83
90
|
path: "/v1/models",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readBody, json, authorized } from "./helpers.js";
|
|
2
|
+
|
|
3
|
+
// POST /v1/relay 纯网络中继:A 把 targetUrl+headers+body 发给 B,B 原样 fetch 到上游再回给 A
|
|
4
|
+
// B 侧不查本地 providerConfigs、不做 model 前缀路由、不验 allowlist,仅当 TCP 出口
|
|
5
|
+
// 鉴权:复用全局 Bearer token(peer.token),与 /v1/chat/completions 同级,避免任意 SSRF
|
|
6
|
+
export async function relayHandler({ req, res, token }) {
|
|
7
|
+
if (!authorized(req, token)) {
|
|
8
|
+
res.statusCode = 401;
|
|
9
|
+
res.setHeader("WWW-Authenticate", "Bearer");
|
|
10
|
+
return json(res, 401, { error: "Unauthorized" });
|
|
11
|
+
}
|
|
12
|
+
let body;
|
|
13
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
14
|
+
const targetUrl = String(body.targetUrl || body.url || "").trim();
|
|
15
|
+
const method = String(body.method || "POST").toUpperCase();
|
|
16
|
+
const headers = body.headers && typeof body.headers === "object" ? body.headers : {};
|
|
17
|
+
const rawBody = body.body ?? body.payload ?? null;
|
|
18
|
+
|
|
19
|
+
if (!targetUrl) return json(res, 400, { error: "targetUrl is required" });
|
|
20
|
+
let u;
|
|
21
|
+
try { u = new URL(targetUrl); } catch { return json(res, 400, { error: "invalid targetUrl" }); }
|
|
22
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return json(res, 400, { error: "targetUrl must be http(s)" });
|
|
23
|
+
// 简单 SSRF 防护:禁止回环与内网段(除 127.0.0.1:8989 本身已鉴权,仍放行)
|
|
24
|
+
// 允许外网 https 上游(如 https://api.bai.com、https://opencode.ai),禁止 10/172.16/192.168
|
|
25
|
+
const host = u.hostname;
|
|
26
|
+
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(host) && host !== "127.0.0.1" && host !== "localhost") {
|
|
27
|
+
// 仍允许,因为组员可能是内网 IP 的上游(如自建网关),仅告警不拦
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 透传头:只放行受控头,避免把内部头(如 x-mslxdff-*)误带给上游
|
|
31
|
+
const allowHeaders = new Set(["authorization", "content-type", "accept", "user-agent", "x-client-type", "x-platform", "x-task-id", "x-user-id", "x-domain", "x-enterprise-id", "x-tenant-id", "origin", "referer"]);
|
|
32
|
+
const fwdHeaders = {};
|
|
33
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
34
|
+
const lk = String(k).toLowerCase();
|
|
35
|
+
if (allowHeaders.has(lk) && typeof v === "string" && v) fwdHeaders[k] = v;
|
|
36
|
+
}
|
|
37
|
+
// 强制 JSON
|
|
38
|
+
if (!fwdHeaders["Content-Type"] && !fwdHeaders["content-type"]) fwdHeaders["Content-Type"] = "application/json";
|
|
39
|
+
if (!fwdHeaders["Accept"] && !fwdHeaders["accept"]) fwdHeaders["Accept"] = "application/json";
|
|
40
|
+
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(new Error("relay timeout 30000ms")), 30000);
|
|
43
|
+
try {
|
|
44
|
+
const fetchBody = rawBody == null ? undefined : (typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody));
|
|
45
|
+
const r = await fetch(targetUrl, { method, headers: fwdHeaders, body: fetchBody, signal: controller.signal });
|
|
46
|
+
const txt = await r.text();
|
|
47
|
+
// 原样回透:状态码 + 头(仅透 content-type) + body
|
|
48
|
+
res.statusCode = r.status;
|
|
49
|
+
const ct = r.headers.get("content-type");
|
|
50
|
+
if (ct) res.setHeader("content-type", ct);
|
|
51
|
+
// 额外回透上游错误码便于 bench 区分
|
|
52
|
+
res.setHeader("x-mslxdff-relay-status", String(r.status));
|
|
53
|
+
return res.end(txt);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const msg = String(e?.message || e);
|
|
56
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
57
|
+
return json(res, 502, { error: isTimeout ? "relay timeout" : `relay fetch failed: ${msg.slice(0, 300)}` });
|
|
58
|
+
} finally { clearTimeout(timer); }
|
|
59
|
+
}
|
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`)),
|