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 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
- const recent = recentEvents(100);
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.39",
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
+ }