mslxdff 0.1.79 → 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 +25 -8
- package/src/bench/via-probe.js +55 -0
- package/src/bench/via.js +6 -2
- 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 +24 -1
- package/src/providers/cline/headers.js +2 -2
- package/src/routes/index.js +7 -0
- package/src/routes/relay.js +59 -0
package/package.json
CHANGED
package/src/bench/report.js
CHANGED
|
@@ -23,8 +23,22 @@ 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
|
+
}
|
|
26
40
|
export function formatViaReport(results, { peers = [], meta = {}, json = false } = {}) {
|
|
27
|
-
const peerIds = (peers || []).map((p) =>
|
|
41
|
+
const peerIds = (peers || []).map((p) => shortPeerLabel(p));
|
|
28
42
|
const samples = meta.samples ?? 1;
|
|
29
43
|
const timeout = meta.timeout ?? 30000;
|
|
30
44
|
const includeOpencode = Boolean(meta.includeOpencode);
|
|
@@ -53,9 +67,11 @@ export function formatViaReport(results, { peers = [], meta = {}, json = false }
|
|
|
53
67
|
return { text: JSON.stringify(jsonObj, null, 2), json: jsonObj };
|
|
54
68
|
}
|
|
55
69
|
const lines = [];
|
|
56
|
-
lines.push(`bench-via: direct vs via peers (samples=${samples}, timeout=${timeout / 1000}s, ${opencodeTag})`);
|
|
70
|
+
lines.push(`bench-via: direct vs via peers (samples=${samples}, timeout=${timeout / 1000}s, ${opencodeTag}) peers=${peerIds.join(",") || "(none)"}`);
|
|
57
71
|
lines.push("");
|
|
58
|
-
|
|
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)}`;
|
|
59
75
|
lines.push(header);
|
|
60
76
|
lines.push("─".repeat(header.length));
|
|
61
77
|
for (const r of results || []) {
|
|
@@ -73,19 +89,20 @@ export function formatViaReport(results, { peers = [], meta = {}, json = false }
|
|
|
73
89
|
let bestKey = r.best;
|
|
74
90
|
if (!bestKey && all.length) bestKey = all.sort((a, b) => a.ttfb - b.ttfb)[0].key;
|
|
75
91
|
const isDirectBest = bestKey === "direct";
|
|
76
|
-
const directCell = pad(`${directTxt}${isDirectBest ? "★" : ""}`,
|
|
92
|
+
const directCell = pad(`${directTxt}${isDirectBest ? "★" : ""}`, colW, "right");
|
|
77
93
|
const viaCells = peerIds.map((pid) => {
|
|
78
94
|
const v = r.via?.[pid];
|
|
79
|
-
if (!v) return pad("—",
|
|
95
|
+
if (!v) return pad("—", colW, "right");
|
|
80
96
|
if (v.ok) {
|
|
81
97
|
const txt = `${v.ttfbMs ?? v.totalMs ?? "—"}ms`;
|
|
82
98
|
const star = bestKey === `via:${pid}` ? "★" : "";
|
|
83
|
-
return pad(`${txt}${star}`,
|
|
99
|
+
return pad(`${txt}${star}`, colW, "right");
|
|
84
100
|
}
|
|
101
|
+
// 失败也展示延迟(先测延迟):如 42ms 鉴权失败,说明网络可达但该组员未配此供应商
|
|
102
|
+
const ms = v.ttfbMs != null ? `${v.ttfbMs}ms ` : "";
|
|
85
103
|
const label = v.label || v.error || "offline";
|
|
86
|
-
// map offline label
|
|
87
104
|
const short = label.includes("离线") ? "offline" : label.slice(0, 8);
|
|
88
|
-
return pad(
|
|
105
|
+
return pad(`${ms}— ${short}`, colW, "right");
|
|
89
106
|
}).join(" ");
|
|
90
107
|
let bestTxt = r.best || "direct";
|
|
91
108
|
if (r.deltaMs != null && r.best?.startsWith("via:")) {
|
package/src/bench/via-probe.js
CHANGED
|
@@ -36,11 +36,64 @@ export async function viaProbe({
|
|
|
36
36
|
timeoutMs = 30000,
|
|
37
37
|
fetchImpl = globalThis.fetch,
|
|
38
38
|
clock = Date.now,
|
|
39
|
+
shareKeys,
|
|
40
|
+
shareKeysHeader,
|
|
41
|
+
relayTarget,
|
|
42
|
+
relayHeaders,
|
|
43
|
+
relayBody,
|
|
44
|
+
targetUrl,
|
|
39
45
|
} = {}) {
|
|
40
46
|
const started = clock();
|
|
41
47
|
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
|
|
42
48
|
const base = String(peerUrl || "").replace(/\/+$/, "");
|
|
43
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
|
+
}
|
|
44
97
|
if (!model) return { ok: false, label: "配置错误", error: "missing model", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
45
98
|
let rawModel = String(model).trim();
|
|
46
99
|
if (providerId && rawModel.startsWith(`${providerId}/`)) rawModel = rawModel.slice(providerId.length + 1);
|
|
@@ -48,6 +101,8 @@ export async function viaProbe({
|
|
|
48
101
|
const body = { model: rawModel, stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
49
102
|
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
|
50
103
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
104
|
+
const sk = shareKeysHeader || shareKeys;
|
|
105
|
+
if (sk) headers["x-mslxdff-share-keys"] = String(sk);
|
|
51
106
|
let ttfbMs = null;
|
|
52
107
|
try {
|
|
53
108
|
const controller = new AbortController();
|
package/src/bench/via.js
CHANGED
|
@@ -99,9 +99,13 @@ export async function orchestrateVia({
|
|
|
99
99
|
}
|
|
100
100
|
const via = {};
|
|
101
101
|
for (const peer of peers) {
|
|
102
|
-
const
|
|
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 || "";
|
|
103
107
|
try {
|
|
104
|
-
const r = await viaProbeFn({ peerUrl: peer.url, token, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
|
|
108
|
+
const r = await viaProbeFn({ peerUrl: peer.url, token: peerToken, providerId: provider, model, prompt: "hi", maxTokens: 5, timeoutMs, clock });
|
|
105
109
|
via[peerId] = r;
|
|
106
110
|
} catch (e) {
|
|
107
111
|
via[peerId] = { ok: false, label: "网络错误", error: String(e?.message || e), ttfbMs: null, totalMs: 0 };
|
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;
|
|
@@ -187,7 +187,30 @@ async function handleVia({ providerId, opts, fetchImpl, loadConfigs, loadKeys, l
|
|
|
187
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
188
|
};
|
|
189
189
|
const { viaProbe } = await import("../../../bench/via-probe.js");
|
|
190
|
-
const
|
|
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
|
+
};
|
|
191
214
|
const part = await orchestrateVia({ models, peers, directRunner, viaProbeFn, includeOpencode, token, timeoutMs: opts.timeoutMs });
|
|
192
215
|
allResults.push(...part);
|
|
193
216
|
if (!opts.json) viaLog(` ${pid}: ${part.length} 模型完成`);
|
|
@@ -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
|
+
}
|