mslxdff 0.1.39 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mslxdff.js +35 -9
- package/package.json +5 -2
- package/src/routes/chat.js +278 -0
- package/src/routes/fallback.js +88 -0
- package/src/routes/groups-relay.js +129 -0
- package/src/routes/groups.js +121 -0
- package/src/routes/helpers.js +66 -0
- package/src/routes/index.js +91 -0
- package/src/routes/models-route.js +31 -0
- package/src/routes/peers.js +125 -0
- package/src/routes/relay-queue.js +127 -0
- package/src/routes/stream.js +194 -0
- package/src/routes.js +3 -1316
- package/src/upstream.js +88 -4
package/bin/mslxdff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import { readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
3
|
+
import { readFileSync, existsSync, statSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join, basename } from "node:path";
|
|
6
6
|
import { startServer, resolvePort } from "../src/server.js";
|
|
@@ -192,14 +192,24 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
192
192
|
// event bus — no filesystem polling. Ctrl+C / SIGTERM restarts the daemon in
|
|
193
193
|
// the background, then exits. See the daemon body below for the stream wiring.
|
|
194
194
|
if (args.includes("-debug") || args.includes("--debug")) {
|
|
195
|
-
|
|
196
|
-
if (recent.length) {
|
|
197
|
-
console.log(`--- last ${recent.length} event(s) ---`);
|
|
198
|
-
for (const e of recent) console.log(fmtEvent(e));
|
|
199
|
-
}
|
|
200
|
-
console.log("--- live (Ctrl+C: stop debugging and restore background daemon) ---");
|
|
195
|
+
// 先停旧 daemon,再清日志,保持 debug 输出干净(仅本次会话)
|
|
201
196
|
const { stopped, pid } = stopDaemon();
|
|
202
197
|
if (stopped) console.log(`[debug] stopped background daemon (pid ${pid})`);
|
|
198
|
+
try {
|
|
199
|
+
const dir = logDir();
|
|
200
|
+
const toClear = [eventsFile(), callsFile(), errorsFile(), logFile()];
|
|
201
|
+
let cleared = 0;
|
|
202
|
+
for (const f of toClear) {
|
|
203
|
+
try {
|
|
204
|
+
if (existsSync(f)) {
|
|
205
|
+
writeFileSync(f, "");
|
|
206
|
+
cleared++;
|
|
207
|
+
}
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
console.log(`[debug] 已清理旧日志 ${cleared} 个文件 (${dir}),本次会话干净输出`);
|
|
211
|
+
} catch {}
|
|
212
|
+
console.log("--- live (Ctrl+C: stop debugging and restore background daemon) ---");
|
|
203
213
|
process.env.MSLXDFF_DEBUG = "1";
|
|
204
214
|
process.env.MSLXDFF_DAEMON = "1";
|
|
205
215
|
// fall through to the daemon body below — no process.exit() here
|
|
@@ -692,6 +702,19 @@ if (isDebug) {
|
|
|
692
702
|
}
|
|
693
703
|
|
|
694
704
|
await srv.ready();
|
|
705
|
+
|
|
706
|
+
// 上游 Keep-Alive 预热:首条 TCP+TLS 暖好,100ms 后异步触发,不阻塞 ready
|
|
707
|
+
setTimeout(() => {
|
|
708
|
+
upstream.preheat().then((r) => {
|
|
709
|
+
const entry = { ts: Date.now(), type: "upstream-preheat", ...r, baseUrl };
|
|
710
|
+
try { bus.emit(entry); } catch {}
|
|
711
|
+
try { logs.appendEvent(entry); } catch {}
|
|
712
|
+
if (r.skipped) console.log(`[preheat] skipped (MSLXDFF_PREHEAT disabled)`);
|
|
713
|
+
else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
|
|
714
|
+
else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
|
|
715
|
+
}).catch(() => {});
|
|
716
|
+
}, 100).unref?.();
|
|
717
|
+
|
|
695
718
|
models.startAutoRefresh();
|
|
696
719
|
if (process.env.MSLXDFF_DAEMON) {
|
|
697
720
|
writePid(process.pid, VERSION);
|
|
@@ -1179,7 +1202,7 @@ function fmtEvent(e) {
|
|
|
1179
1202
|
const t = e?.ts ? new Date(e.ts).toISOString().slice(11, 19) : "--:--:--";
|
|
1180
1203
|
const head = `[${t}]`;
|
|
1181
1204
|
const m = (x) => x || "-";
|
|
1182
|
-
const fallbackTag = e.fallback ? ` fallback=${e.fallback.requested_model || e.requested}->${e.fallback.actual_model || e.actual}(${e.fallback.reason || e.reason})` : "";
|
|
1205
|
+
const fallbackTag = e.fallback?.fallback ? ` fallback=${e.fallback.requested_model || e.requested}->${e.fallback.actual_model || e.actual}(${e.fallback.reason || e.reason})` : "";
|
|
1183
1206
|
switch (e?.type) {
|
|
1184
1207
|
case "request":
|
|
1185
1208
|
return `${head} request client-> ${m(e.requested || e.model)}${e.auto ? " (auto)" : ""} raw=${m(e.rawModel)} lock=${m(e.lockModel)} hops=${e.hops} from ${e.ip || "?"}${e.stream ? " stream" : ""}${e.prompt ? ` content="${e.prompt}"` : ""} reqId=${e.reqId || ""}`;
|
|
@@ -1195,6 +1218,9 @@ function fmtEvent(e) {
|
|
|
1195
1218
|
return `${head} upstream ok ${m(e.model)} HTTP ${e.status}${e.timing ? ` total=${e.timing.totalMs}ms` : ""}`;
|
|
1196
1219
|
case "upstream-error":
|
|
1197
1220
|
return `${head} upstream err ${m(e.model)} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
|
|
1221
|
+
case "upstream-preheat":
|
|
1222
|
+
if (e.skipped) return `${head} preheat 跳过 (MSLXDFF_PREHEAT disabled)`;
|
|
1223
|
+
return `${head} preheat 预热 opencode models ${e.ok ? "ok" : "fail"} ${e.status ? `HTTP ${e.status}` : e.error || ""} ${e.ms ? `${e.ms}ms` : ""}`;
|
|
1198
1224
|
case "peer-race-start":
|
|
1199
1225
|
return `${head} peer race 开始并发给组员 model=${m(e.model)} peers=${e.peers}`;
|
|
1200
1226
|
case "peer-health":
|
|
@@ -1228,7 +1254,7 @@ function fmtEvent(e) {
|
|
|
1228
1254
|
case "result":
|
|
1229
1255
|
return `${head} result 返回客户端 status=${e.status} model=${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}${fallbackTag}`;
|
|
1230
1256
|
case "client-response":
|
|
1231
|
-
return `${head} client res 返回客户端 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} via=${m(e.via)}${e.fallback ? ` fallback=${e.fallback.requested_model}->${e.fallback.actual_model}(${e.fallback.reason})` : " 无fallback"} status=${e.status}`;
|
|
1257
|
+
return `${head} client res 返回客户端 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} via=${m(e.via)}${e.fallback?.fallback ? ` fallback=${e.fallback.requested_model}->${e.fallback.actual_model}(${e.fallback.reason})` : " 无fallback"} status=${e.status}`;
|
|
1232
1258
|
case "auto-update-enabled":
|
|
1233
1259
|
return `${head} auto-update enabled every ${Math.round((e.intervalMs||0)/60000)}m current=${e.current}`;
|
|
1234
1260
|
case "auto-update-disabled":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mslxdff",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "测试项目,请勿使用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,5 +23,8 @@
|
|
|
23
23
|
"demo",
|
|
24
24
|
"placeholder"
|
|
25
25
|
],
|
|
26
|
-
"license": "MIT"
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"undici": "^8.10.0"
|
|
29
|
+
}
|
|
27
30
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { injectReasoningContent, normalizeModel } from "../reasoning.js";
|
|
3
|
+
import { isAutoModel } from "../auto.js";
|
|
4
|
+
import { clientIp, json, readBody, parseHops, summarizePrompt, errMsg } from "./helpers.js";
|
|
5
|
+
import { buildFallbackInfo } from "./fallback.js";
|
|
6
|
+
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "./stream.js";
|
|
7
|
+
import { racePeerCandidates } from "./peers.js";
|
|
8
|
+
import { tryBroadbandRelay } from "./relay-queue.js";
|
|
9
|
+
|
|
10
|
+
export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token }) {
|
|
11
|
+
let body;
|
|
12
|
+
try {
|
|
13
|
+
body = await readBody(req);
|
|
14
|
+
} catch {
|
|
15
|
+
return json(res, 400, { error: "Invalid JSON body" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const startedAt = Date.now();
|
|
19
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
20
|
+
const perf0 = performance.now();
|
|
21
|
+
const stages = [];
|
|
22
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
23
|
+
const hops = parseHops(req.headers["x-mslxdff-hops"]);
|
|
24
|
+
const lockModel = req.headers["x-mslxdff-model-lock"] || "";
|
|
25
|
+
const rawModel = body.model || "";
|
|
26
|
+
const requested = normalizeModel(lockModel || rawModel || "");
|
|
27
|
+
const useAuto = isAutoModel(requested);
|
|
28
|
+
mark("parsed");
|
|
29
|
+
|
|
30
|
+
let order;
|
|
31
|
+
if (lockModel) {
|
|
32
|
+
order = [requested];
|
|
33
|
+
} else if (useAuto) {
|
|
34
|
+
order = auto ? await auto.candidates() : [""];
|
|
35
|
+
} else {
|
|
36
|
+
order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
37
|
+
}
|
|
38
|
+
if (!order.length) order = [""];
|
|
39
|
+
const canFallback = order.length > 1;
|
|
40
|
+
const canForwardPeers = Boolean(peers) && hops < maxHops;
|
|
41
|
+
mark("ordered");
|
|
42
|
+
|
|
43
|
+
const logCall = (model, status) =>
|
|
44
|
+
logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream), stages });
|
|
45
|
+
const logError = (model, status, message) =>
|
|
46
|
+
logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
47
|
+
const evt = (type, data) => {
|
|
48
|
+
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
49
|
+
if (bus) bus.emit(entry);
|
|
50
|
+
logs?.appendEvent?.(entry);
|
|
51
|
+
};
|
|
52
|
+
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
|
|
53
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
54
|
+
|
|
55
|
+
const handlerCtx = {
|
|
56
|
+
model: null,
|
|
57
|
+
body,
|
|
58
|
+
hops,
|
|
59
|
+
peers,
|
|
60
|
+
evt,
|
|
61
|
+
logError,
|
|
62
|
+
logCall,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let lastErr = null;
|
|
66
|
+
for (let idx = 0; idx < order.length; idx++) {
|
|
67
|
+
const model = order[idx];
|
|
68
|
+
handlerCtx.model = model;
|
|
69
|
+
evt("model-try", { reqId, model, idx, remaining: order.length - idx });
|
|
70
|
+
let upRes = null;
|
|
71
|
+
const forwarded = { ...injectReasoningContent(model, body), model };
|
|
72
|
+
const tUp = performance.now();
|
|
73
|
+
evt("upstream-try", { reqId, model, attempt: idx + 1 });
|
|
74
|
+
try {
|
|
75
|
+
upRes = await upstream.chat(forwarded);
|
|
76
|
+
evt("upstream-done", { reqId, model, ok: !(upRes instanceof Error) && upRes.status < 400, status: upRes instanceof Error ? null : upRes.status, timing: upRes._t ?? null, error: null });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (auto) await auto.recordError(model, { message: errMsg(err) });
|
|
79
|
+
lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
|
|
80
|
+
logError(model, 502, errMsg(err));
|
|
81
|
+
evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
|
|
82
|
+
}
|
|
83
|
+
mark(`up-${model}`);
|
|
84
|
+
if (upRes && upRes.status >= 400) {
|
|
85
|
+
if (auto) await auto.recordError(model, { status: upRes.status });
|
|
86
|
+
lastErr = { model, upstream: upRes, status: upRes.status, message: null };
|
|
87
|
+
logError(model, upRes.status, `upstream ${upRes.status}`);
|
|
88
|
+
evt("upstream-error", { reqId, model, status: upRes.status, message: null, timing: upRes._t ?? null });
|
|
89
|
+
upRes = null;
|
|
90
|
+
}
|
|
91
|
+
if (upRes) {
|
|
92
|
+
logCall(model, upRes.status);
|
|
93
|
+
const fallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "local", useAuto, lockModel });
|
|
94
|
+
if (fallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: fallback.reason, notice: fallback.notice, via: "local" });
|
|
95
|
+
evt("relay-start", { reqId, model, via: "local", isStream: Boolean(body.stream), fallback });
|
|
96
|
+
const out = await relay(res, upRes, body, {
|
|
97
|
+
fallback,
|
|
98
|
+
onFirstChunk: (delta) => {
|
|
99
|
+
mark(`ttf-${model}`);
|
|
100
|
+
evt("relay-first-chunk", { reqId, model, ttfMs: delta });
|
|
101
|
+
},
|
|
102
|
+
onDownstreamAbort: () => {
|
|
103
|
+
evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
evt("relay-done", { reqId, model, via: "local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
107
|
+
if (out.status === STREAM_TIMEOUT_MS) {
|
|
108
|
+
if (auto) await auto.recordError(model, { status: 502, slow: true, note: `stream timeout ${STREAM_TIMEOUT_MS}ms` });
|
|
109
|
+
lastErr = { model, upstream: null, status: 502, message: `stream timed out after ${STREAM_TIMEOUT_MS}ms` };
|
|
110
|
+
logError(model, 502, `stream timeout ${STREAM_TIMEOUT_MS}ms`);
|
|
111
|
+
evt("upstream-error", { reqId, model, status: 502, message: "stream timeout", timing: null });
|
|
112
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: "stream timeout" });
|
|
113
|
+
upRes = null;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (out.interrupted) {
|
|
117
|
+
if (auto) {
|
|
118
|
+
await auto.recordError(model, { status: 200, slow: true, note: `stall ${STALL_TIMEOUT_MS}ms` });
|
|
119
|
+
await auto.recordLatency(model, out.totalMs ?? (Date.now() - startedAt));
|
|
120
|
+
}
|
|
121
|
+
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
|
|
122
|
+
logCall(model, 200);
|
|
123
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual: model });
|
|
124
|
+
evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, interrupted: true, reqId });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const elapsed = Date.now() - startedAt;
|
|
128
|
+
const latencyMs = out.totalMs ?? elapsed;
|
|
129
|
+
let scoredSlow = false;
|
|
130
|
+
if (SLOW_TOTAL_MS && auto && elapsed > SLOW_TOTAL_MS && out.status === 200) {
|
|
131
|
+
void auto.recordError(model, { status: 200, slow: true, note: `slow ${elapsed}ms` });
|
|
132
|
+
void auto.recordLatency(model, latencyMs);
|
|
133
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null });
|
|
134
|
+
scoredSlow = true;
|
|
135
|
+
}
|
|
136
|
+
if (out.detail?.stallHits > 0 && auto && out.status === 200) {
|
|
137
|
+
void auto.recordError(model, { status: 200, slow: true, note: `stall ${out.detail.stallHits}x gap>${SCORE_STALL_MS}ms maxGap ${out.detail.maxGapMs}ms` });
|
|
138
|
+
void auto.recordLatency(model, latencyMs);
|
|
139
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null });
|
|
140
|
+
scoredSlow = true;
|
|
141
|
+
}
|
|
142
|
+
if (!scoredSlow && auto && out.status === 200) {
|
|
143
|
+
await auto.recordOk(model, { latencyMs });
|
|
144
|
+
} else if (!scoredSlow && auto) {
|
|
145
|
+
await auto.recordLatency(model, latencyMs);
|
|
146
|
+
}
|
|
147
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual: model });
|
|
148
|
+
evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, reqId });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (canForwardPeers) {
|
|
153
|
+
evt("peer-race-start", { reqId, model, peers: peers.ordered().length });
|
|
154
|
+
const win =
|
|
155
|
+
(await racePeerCandidates(peers.ordered(), handlerCtx)) ||
|
|
156
|
+
(await racePeerCandidates(peers.orderedByLastError(), handlerCtx));
|
|
157
|
+
if (win) {
|
|
158
|
+
evt("peer-race-win", { reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
|
|
159
|
+
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
160
|
+
logCall(win.target, win.res.status);
|
|
161
|
+
const peerFallback = buildFallbackInfo({ requested, actual: win.target, lastErr, via: "peer", useAuto, lockModel });
|
|
162
|
+
if (peerFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: win.target, reason: peerFallback.reason, notice: peerFallback.notice, via: "peer" });
|
|
163
|
+
evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream), fallback: peerFallback });
|
|
164
|
+
const out = await relay(res, win.res, body, {
|
|
165
|
+
fallback: peerFallback,
|
|
166
|
+
onFirstChunk: (d) => mark(`ttf-peer-${win.target}`),
|
|
167
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: win.target, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
168
|
+
});
|
|
169
|
+
evt("relay-done", { reqId, model: win.target, via: "peer", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
170
|
+
if (auto && out.status === 200) {
|
|
171
|
+
const latencyMs = out.totalMs ?? win.latencyMs;
|
|
172
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
173
|
+
void auto.recordError(win.target, { status: 200, slow: true, note: `peer slow ${latencyMs}ms` });
|
|
174
|
+
void auto.recordLatency(win.target, latencyMs);
|
|
175
|
+
} else {
|
|
176
|
+
await auto.recordOk(win.target, { latencyMs });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: peerFallback, requested, actual: win.target });
|
|
180
|
+
evt("client-response", { requested, actual: win.target, via: "peer", fallback: peerFallback, status: out.status, reqId });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
evt("peer-race-lose", { reqId, model });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (groups) {
|
|
187
|
+
const bb = await tryBroadbandRelay({ groups, token, model, body, hops, bus, logs, reqId, evt, res, mark, perf0, stages });
|
|
188
|
+
if (bb) {
|
|
189
|
+
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
190
|
+
if (isResponse) {
|
|
191
|
+
const bbFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
192
|
+
if (bbFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbFallback.reason, notice: bbFallback.notice, via: "broadband" });
|
|
193
|
+
evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group, fallback: bbFallback });
|
|
194
|
+
const out = await relay(res, bb.result, body, {
|
|
195
|
+
fallback: bbFallback,
|
|
196
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
197
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
198
|
+
});
|
|
199
|
+
evt("relay-done", { reqId, model, via: "broadband", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
200
|
+
if (auto && out.status === 200) {
|
|
201
|
+
const latencyMs = out.totalMs ?? 0;
|
|
202
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
203
|
+
void auto.recordError(model, { status: 200, slow: true, note: `broadband slow ${latencyMs}ms` });
|
|
204
|
+
void auto.recordLatency(model, latencyMs);
|
|
205
|
+
} else {
|
|
206
|
+
await auto.recordOk(model, { latencyMs });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
evt("result", { model, status: out.status, via: "broadband", timing: bb.result._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbFallback, requested, actual: model });
|
|
210
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbFallback, status: out.status, reqId });
|
|
211
|
+
return;
|
|
212
|
+
} else if (bb.result && typeof bb.result.status === "number") {
|
|
213
|
+
const fakeRes = {
|
|
214
|
+
status: bb.result.status,
|
|
215
|
+
headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
|
|
216
|
+
text: async () => typeof bb.result.body === "string" ? bb.result.body : JSON.stringify(bb.result.body),
|
|
217
|
+
body: (() => {
|
|
218
|
+
const b = bb.result.body || "";
|
|
219
|
+
const str = typeof b === "string" ? b : JSON.stringify(b);
|
|
220
|
+
const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
|
|
221
|
+
if (isSSE) {
|
|
222
|
+
return (async function* () { yield Buffer.from(str); })();
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
})(),
|
|
226
|
+
};
|
|
227
|
+
const bbLocalFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
228
|
+
if (bbLocalFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbLocalFallback.reason, notice: bbLocalFallback.notice, via: "broadband" });
|
|
229
|
+
evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group, fallback: bbLocalFallback });
|
|
230
|
+
const out = await relay(res, fakeRes, body, {
|
|
231
|
+
fallback: bbLocalFallback,
|
|
232
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
233
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
234
|
+
});
|
|
235
|
+
evt("relay-done", { reqId, model, via: "broadband-local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
236
|
+
evt("result", { model, status: out.status, via: "broadband", timing: null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbLocalFallback, requested, actual: model });
|
|
237
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbLocalFallback, status: out.status, reqId });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
evt("relay-miss", { reqId, model });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (canFallback) {
|
|
245
|
+
evt("fallback", { reqId, from: model, to: order[idx + 1] ?? null, reason: lastErr?.message || `upstream ${lastErr?.status ?? 502}` });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
evt("exhausted-local", { reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
|
|
249
|
+
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
250
|
+
if (lastErr?.upstream) {
|
|
251
|
+
evt("relay-start", { reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
|
|
252
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
253
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
254
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
255
|
+
});
|
|
256
|
+
evt("relay-done", { reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
257
|
+
evt("result", { reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
evt("result", { reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
261
|
+
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
evt("exhausted-all", { reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
265
|
+
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
266
|
+
if (lastErr?.upstream) {
|
|
267
|
+
evt("relay-start", { reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
|
|
268
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
269
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
270
|
+
onDownstreamAbort: () => evt("client-abort", { reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
271
|
+
});
|
|
272
|
+
evt("relay-done", { reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
273
|
+
evt("result", { reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
evt("result", { reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
277
|
+
return json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
278
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
function fallbackReason(lastErr) {
|
|
2
|
+
if (!lastErr) return "cooldown";
|
|
3
|
+
const s = Number(lastErr.status);
|
|
4
|
+
if (s === 429) return "rate_limited";
|
|
5
|
+
if (lastErr.message && /timeout/i.test(String(lastErr.message))) return "timeout";
|
|
6
|
+
if (s === 502 || s === 503 || s === 504) return "upstream_error";
|
|
7
|
+
if (s >= 400) return "upstream_error";
|
|
8
|
+
return "fallback";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildFallbackInfo({ requested, actual, lastErr, via, useAuto, lockModel }) {
|
|
12
|
+
if (!requested || !actual) return null;
|
|
13
|
+
const alwaysHeaders = {
|
|
14
|
+
requested_model: requested,
|
|
15
|
+
actual_model: actual,
|
|
16
|
+
via: via || "local",
|
|
17
|
+
};
|
|
18
|
+
if (useAuto || lockModel) {
|
|
19
|
+
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
20
|
+
}
|
|
21
|
+
const isFallback = requested !== actual;
|
|
22
|
+
if (!isFallback) {
|
|
23
|
+
return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
|
|
24
|
+
}
|
|
25
|
+
const reason = fallbackReason(lastErr);
|
|
26
|
+
const reasonZh = reason === "rate_limited" ? "限流" : reason === "timeout" ? "超时" : reason === "cooldown" ? "冷却中" : "不可用";
|
|
27
|
+
const notice = `${requested} ${reasonZh},已由 ${actual} 代答`;
|
|
28
|
+
return { ...alwaysHeaders, fallback: true, reason, notice };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyFallbackHeaders(res, info) {
|
|
32
|
+
if (!info) return;
|
|
33
|
+
if (info.requested_model) res.setHeader("x-mslxdff-requested-model", info.requested_model);
|
|
34
|
+
if (info.actual_model) res.setHeader("x-mslxdff-actual-model", info.actual_model);
|
|
35
|
+
if (info.via) res.setHeader("x-mslxdff-via", info.via);
|
|
36
|
+
if (info.fallback) {
|
|
37
|
+
res.setHeader("x-mslxdff-fallback", "1");
|
|
38
|
+
if (info.reason) res.setHeader("x-mslxdff-fallback-reason", info.reason);
|
|
39
|
+
if (info.notice) res.setHeader("x-mslxdff-notice", encodeURIComponent(info.notice));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function enrichNonStreamJson(obj, info) {
|
|
44
|
+
if (!info || typeof obj !== "object" || obj === null) return obj;
|
|
45
|
+
if (!info.fallback) return obj;
|
|
46
|
+
if (obj.mslxdff) return obj;
|
|
47
|
+
return {
|
|
48
|
+
...obj,
|
|
49
|
+
mslxdff: {
|
|
50
|
+
fallback: true,
|
|
51
|
+
requested_model: info.requested_model,
|
|
52
|
+
actual_model: info.actual_model,
|
|
53
|
+
reason: info.reason,
|
|
54
|
+
via: info.via,
|
|
55
|
+
notice: info.notice,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function enrichSseChunkText(text, info) {
|
|
61
|
+
if (!info?.fallback) return text;
|
|
62
|
+
const lines = text.split("\n");
|
|
63
|
+
let changed = false;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i];
|
|
66
|
+
const m = /^data:\s*(\{.*\})\s*$/.exec(line);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
try {
|
|
69
|
+
const obj = JSON.parse(m[1]);
|
|
70
|
+
if (obj && typeof obj === "object" && !obj.mslxdff) {
|
|
71
|
+
obj.mslxdff = {
|
|
72
|
+
fallback: true,
|
|
73
|
+
requested_model: info.requested_model,
|
|
74
|
+
actual_model: info.actual_model,
|
|
75
|
+
reason: info.reason,
|
|
76
|
+
via: info.via,
|
|
77
|
+
notice: info.notice,
|
|
78
|
+
};
|
|
79
|
+
lines[i] = `data: ${JSON.stringify(obj)}`;
|
|
80
|
+
changed = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return changed ? lines.join("\n") : text;
|
|
88
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { clientIp, json, readBody, parseHops, errMsg } from "./helpers.js";
|
|
2
|
+
import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
3
|
+
import { enqueueRelay, dequeueRelayForPoll, resolveRelay } from "./relay-queue.js";
|
|
4
|
+
|
|
5
|
+
export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
6
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
7
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
8
|
+
let body;
|
|
9
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
10
|
+
const groupName = body?.name || body?.group;
|
|
11
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
12
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
13
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
14
|
+
const ip = clientIp(req);
|
|
15
|
+
try {
|
|
16
|
+
const memberUrl = hit.member?.url;
|
|
17
|
+
const members = groups.list()[groupName]?.members || {};
|
|
18
|
+
const targetId = Object.keys(members).find((k) => members[k].url === memberUrl) || hit.member?.url;
|
|
19
|
+
if (hit.member?.kind === "broadband" || String(memberUrl).startsWith("relay://")) {
|
|
20
|
+
const m = members[targetId] || hit.member;
|
|
21
|
+
if (m) {
|
|
22
|
+
m.publicIp = ip;
|
|
23
|
+
m.lastSeen = Date.now();
|
|
24
|
+
try { groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen }); } catch {}
|
|
25
|
+
}
|
|
26
|
+
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, group: groupName };
|
|
27
|
+
if (bus) bus.emit(evtData);
|
|
28
|
+
logs?.appendEvent?.(evtData);
|
|
29
|
+
}
|
|
30
|
+
return json(res, 200, { object: "heartbeat", ok: true, ip, lastSeen: Date.now() });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return json(res, 400, { error: errMsg(err) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function pollHandler({ req, res, groups }) {
|
|
37
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
38
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
39
|
+
let body;
|
|
40
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
41
|
+
const groupName = body?.name || body?.group;
|
|
42
|
+
if (!groupName) return json(res, 400, { error: "group name is required" });
|
|
43
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
44
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
45
|
+
const targetUrl = hit.member?.url;
|
|
46
|
+
if (!targetUrl) return json(res, 400, { error: "member url not found" });
|
|
47
|
+
const batch = dequeueRelayForPoll({ group: groupName, target: targetUrl, limit: 10 });
|
|
48
|
+
return json(res, 200, { object: "poll", data: batch });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function resultHandler({ req, res, groups }) {
|
|
52
|
+
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
53
|
+
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
54
|
+
let body;
|
|
55
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
56
|
+
const groupName = body?.name || body?.group;
|
|
57
|
+
const reqId = body?.reqId;
|
|
58
|
+
if (!groupName || !reqId) return json(res, 400, { error: "group and reqId required" });
|
|
59
|
+
const hit = groups?.membersForToken(groupName, auth[1]);
|
|
60
|
+
if (!hit) return json(res, 403, { error: "invalid member token" });
|
|
61
|
+
const ok = resolveRelay(reqId, body.result || body);
|
|
62
|
+
if (!ok) return json(res, 404, { error: "pending request not found or timed out" });
|
|
63
|
+
return json(res, 200, { object: "result", ok: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function forwardHandler({ req, res, groups, bus, logs }) {
|
|
67
|
+
let body;
|
|
68
|
+
try { body = await readBody(req); } catch { return json(res, 400, { error: "Invalid JSON body" }); }
|
|
69
|
+
const groupName = body?.group || body?.name;
|
|
70
|
+
const target = body?.target || body?.url;
|
|
71
|
+
const hops = parseHops(req.headers["x-mslxdff-hops"] || body?.hops);
|
|
72
|
+
if (!groupName || !target) return json(res, 400, { error: "group and target required" });
|
|
73
|
+
if (hops >= DEFAULT_MAX_HOPS) return json(res, 429, { error: "max hops exceeded" });
|
|
74
|
+
const members = groups?.list()[groupName]?.members || {};
|
|
75
|
+
const targetMember = Object.values(members).find((m) => m.url === target) || Object.entries(members).find(([id]) => id === target)?.[1];
|
|
76
|
+
if (!targetMember) return json(res, 404, { error: `target ${target} not found in group ${groupName}` });
|
|
77
|
+
const isBb = targetMember.kind === "broadband" || String(targetMember.url).startsWith("relay://");
|
|
78
|
+
if (!isBb) {
|
|
79
|
+
try {
|
|
80
|
+
const fwdBody = body.body || body;
|
|
81
|
+
const r = await fetch(`${targetMember.url}/v1/chat/completions`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${targetMember.token || ""}`, "x-mslxdff-hops": String(hops + 1), "x-mslxdff-model-lock": fwdBody.model || "", "Accept": "text/event-stream" },
|
|
84
|
+
body: JSON.stringify(fwdBody),
|
|
85
|
+
});
|
|
86
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "direct", group: groupName, hops };
|
|
87
|
+
if (bus) bus.emit(evtData);
|
|
88
|
+
logs?.appendEvent?.(evtData);
|
|
89
|
+
res.statusCode = r.status;
|
|
90
|
+
if (r.headers.get("content-type")?.includes("text/event-stream")) {
|
|
91
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
92
|
+
if (r.body) for await (const c of r.body) res.write(c);
|
|
93
|
+
res.end();
|
|
94
|
+
} else {
|
|
95
|
+
const txt = await r.text();
|
|
96
|
+
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
97
|
+
res.end(txt);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return json(res, 502, { error: errMsg(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const reqId = body.reqId || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
105
|
+
const fwdBody = body.body || { model: body.model, messages: body.messages, stream: body.stream };
|
|
106
|
+
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "leader", group: groupName, hops, reqId, model: fwdBody.model };
|
|
107
|
+
if (bus) bus.emit(evtData);
|
|
108
|
+
logs?.appendEvent?.(evtData);
|
|
109
|
+
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
110
|
+
if (typeof targetMember.lastSeen === "number" && Date.now() - targetMember.lastSeen > staleMs) {
|
|
111
|
+
return json(res, 502, { error: "broadband member stale (no heartbeat)" });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const resultPromise = enqueueRelay({ group: groupName, target: targetMember.url, reqId, body: fwdBody, hops });
|
|
115
|
+
const result = await resultPromise;
|
|
116
|
+
if (result && typeof result.status === "number") {
|
|
117
|
+
res.statusCode = result.status;
|
|
118
|
+
if (result.headers) for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
|
|
119
|
+
if (result.body) {
|
|
120
|
+
if (typeof result.body === "string") res.end(result.body);
|
|
121
|
+
else res.end(JSON.stringify(result.body));
|
|
122
|
+
} else res.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
return json(res, 200, result);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return json(res, 504, { error: errMsg(err) || "relay timeout" });
|
|
128
|
+
}
|
|
129
|
+
}
|