mslxdff 0.1.34 → 0.1.36

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
@@ -804,36 +804,85 @@ if (broadbandGroups().length) {
804
804
  }
805
805
 
806
806
  // Auto-update: periodically check npm for a newer mslxdff and restart.
807
- // Enable with MSLXDFF_AUTO_UPDATE=1 (hourly) or MSLXDFF_AUTO_UPDATE_MS=<ms>.
808
- // Uses the same npm view/install path as `mslxdff -update`, but runs inside
809
- // the daemon so no manual intervention is needed.
807
+ // Default: hourly (no env needed). Disable with MSLXDFF_AUTO_UPDATE=0/off/false.
808
+ // Tuning: MSLXDFF_AUTO_UPDATE=1/true hourly, or MSLXDFF_AUTO_UPDATE_MS=<ms>.
810
809
  const autoUpdateMs = autoUpdateIntervalMs();
810
+ function emitAutoUpdate(type, data = {}) {
811
+ const entry = { ts: Date.now(), type, ...data };
812
+ try { bus?.emit(entry); } catch {}
813
+ try { logs?.appendEvent?.(entry); } catch {}
814
+ // also to daemon.log for tail
815
+ const line = `[auto-update] ${type} ${JSON.stringify(data)}`;
816
+ console.log(line);
817
+ }
811
818
  if (autoUpdateMs) {
812
819
  console.log(`auto-update enabled: checking every ${Math.round(autoUpdateMs / 60000)}m`);
820
+ emitAutoUpdate("auto-update-enabled", { intervalMs: autoUpdateMs, current: VERSION });
821
+ // run once shortly after start (30s) so a newly deployed fix is picked up quickly,
822
+ // then on the regular interval
823
+ setTimeout(() => {
824
+ emitAutoUpdate("auto-update-check", { current: VERSION });
825
+ checkAndAutoUpdate().catch((err) => {
826
+ console.log(`auto-update check failed: ${errMsg(err)}`);
827
+ emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
828
+ });
829
+ }, 30_000).unref?.();
813
830
  const autoUpdateTimer = setInterval(() => {
814
- checkAndAutoUpdate().catch((err) => console.log(`auto-update check failed: ${errMsg(err)}`));
831
+ emitAutoUpdate("auto-update-check", { current: VERSION });
832
+ checkAndAutoUpdate().catch((err) => {
833
+ console.log(`auto-update check failed: ${errMsg(err)}`);
834
+ emitAutoUpdate("auto-update-failed", { error: errMsg(err) });
835
+ });
815
836
  }, autoUpdateMs);
816
837
  autoUpdateTimer.unref();
838
+ } else {
839
+ console.log(`auto-update disabled (set MSLXDFF_AUTO_UPDATE=1 to enable hourly)`);
840
+ emitAutoUpdate("auto-update-disabled", { current: VERSION });
817
841
  }
818
842
 
819
843
  async function checkAndAutoUpdate() {
820
- const info = await run(npmCmd(), ["view", "mslxdff", "version", "dist-tags.latest"]);
821
- if (info.err) throw new Error(info.err.message);
822
- const parts = (info.stdout || "").trim().split(/\s+/).filter(Boolean);
823
- const latest = parts[parts.length - 1];
824
- if (!latest || latest === VERSION) return;
825
- // simple semver compare: skip if latest is not newer
826
- if (compareSemver(latest, VERSION) <= 0) return;
844
+ emitAutoUpdate("auto-update-query", { current: VERSION });
845
+ const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
846
+ if (info.err) {
847
+ emitAutoUpdate("auto-update-query-failed", { error: info.err.message || String(info.stderr || "").slice(0, 500) });
848
+ throw new Error(info.err.message || String(info.stderr || "").slice(0, 500));
849
+ }
850
+ let latest = "";
851
+ try {
852
+ latest = JSON.parse(String(info.stdout || "").trim());
853
+ if (Array.isArray(latest)) latest = latest[latest.length - 1];
854
+ latest = String(latest || "").replace(/^v/, "").trim();
855
+ } catch {
856
+ const raw = String(info.stdout || "").trim();
857
+ const m = raw.match(/(\d+\.\d+\.\d+[^\s'"]*)/);
858
+ latest = m ? m[1] : raw.split(/\s+/).pop()?.replace(/['"]/g, "") || "";
859
+ }
860
+ latest = latest.replace(/['"]/g, "").trim();
861
+ emitAutoUpdate("auto-update-queried", { current: VERSION, latest, stdout: String(info.stdout || "").trim().slice(0, 200) });
862
+ if (!latest || latest === VERSION) {
863
+ emitAutoUpdate("auto-update-noop", { current: VERSION, latest });
864
+ return;
865
+ }
866
+ if (compareSemver(latest, VERSION) <= 0) {
867
+ emitAutoUpdate("auto-update-noop", { current: VERSION, latest, reason: "not newer" });
868
+ return;
869
+ }
870
+ emitAutoUpdate("auto-update-found", { current: VERSION, latest });
827
871
  console.log(`auto-update: v${VERSION} -> v${latest}, installing...`);
872
+ emitAutoUpdate("auto-update-installing", { current: VERSION, latest });
828
873
  const up = await run(npmCmd(), ["install", "-g", `mslxdff@${latest}`]);
829
- if (up.err) throw new Error(up.err.message);
874
+ if (up.err) {
875
+ emitAutoUpdate("auto-update-install-failed", { current: VERSION, latest, error: up.err.message || String(up.stderr || "").slice(0, 500) });
876
+ throw new Error(up.err.message || String(up.stderr || "").slice(0, 500));
877
+ }
878
+ emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
830
879
  console.log(`auto-update: installed v${latest}, restarting daemon...`);
831
- try { stopDaemon(); } catch {}
832
- // startDaemon re-reads VERSION from the newly installed package on next boot;
833
- // for the current process we just respawn with the new code.
880
+ emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
881
+ try { stopDaemon(); } catch (e) { emitAutoUpdate("auto-update-stop-failed", { error: errMsg(e) }); }
834
882
  const newPid = startDaemon([]);
835
883
  await waitForHealth(resolvePort(), 8000);
836
884
  console.log(`auto-update: restarted as v${latest} (pid ${newPid})`);
885
+ emitAutoUpdate("auto-update-restarted", { current: VERSION, latest, newPid });
837
886
  }
838
887
 
839
888
  function compareSemver(a, b) {
@@ -906,10 +955,13 @@ function groupSyncIntervalMs() {
906
955
 
907
956
  function autoUpdateIntervalMs() {
908
957
  const raw = process.env.MSLXDFF_AUTO_UPDATE_MS ?? process.env.MSLXDFF_AUTO_UPDATE;
909
- if (raw === undefined || raw === null || raw === "") return 0;
910
- if (raw === "1" || String(raw).toLowerCase() === "true") return 60 * 60 * 1000;
958
+ // default: hourly when env not set; explicit 0/off/false disables
959
+ if (raw === undefined || raw === null || raw === "") return 60 * 60 * 1000;
960
+ const s = String(raw).trim().toLowerCase();
961
+ if (s === "0" || s === "off" || s === "false" || s === "no" || s === "disable" || s === "disabled") return 0;
962
+ if (s === "1" || s === "true" || s === "on" || s === "yes" || s === "enable" || s === "enabled") return 60 * 60 * 1000;
911
963
  const n = Number(raw);
912
- return Number.isInteger(n) && n > 0 ? n : 0;
964
+ return Number.isInteger(n) && n > 0 ? n : 60 * 60 * 1000;
913
965
  }
914
966
 
915
967
  function banWindowMs() {
@@ -979,7 +1031,7 @@ Environment:
979
1031
  MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
980
1032
  MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
981
1033
  MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
982
- MSLXDFF_AUTO_UPDATE auto-update: 1/true=hourly, or ms interval (0=off)
1034
+ MSLXDFF_AUTO_UPDATE auto-update: hourly by default, 0/off/false to disable, 1/true or ms
983
1035
  MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
984
1036
  `);
985
1037
  }
@@ -1148,6 +1200,32 @@ function fmtEvent(e) {
1148
1200
  return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
1149
1201
  case "result":
1150
1202
  return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
1203
+ case "auto-update-enabled":
1204
+ return `${head} auto-update enabled every ${Math.round((e.intervalMs||0)/60000)}m current=${e.current}`;
1205
+ case "auto-update-disabled":
1206
+ return `${head} auto-update disabled current=${e.current}`;
1207
+ case "auto-update-check":
1208
+ return `${head} auto-update checking current=${e.current}`;
1209
+ case "auto-update-query":
1210
+ return `${head} auto-update querying npm current=${e.current}`;
1211
+ case "auto-update-queried":
1212
+ return `${head} auto-update queried current=${e.current} latest=${e.latest} raw=${(e.stdout||"").slice(0,80)}`;
1213
+ case "auto-update-noop":
1214
+ return `${head} auto-update noop current=${e.current} latest=${e.latest}${e.reason?` reason=${e.reason}`:""}`;
1215
+ case "auto-update-found":
1216
+ return `${head} auto-update NEW v${e.current} -> v${e.latest} 发现新版本`;
1217
+ case "auto-update-installing":
1218
+ return `${head} auto-update installing v${e.latest}...`;
1219
+ case "auto-update-installed":
1220
+ return `${head} auto-update installed v${e.latest}`;
1221
+ case "auto-update-restarting":
1222
+ return `${head} auto-update restarting daemon to v${e.latest}...`;
1223
+ case "auto-update-restarted":
1224
+ return `${head} auto-update restarted pid=${e.newPid} v${e.current} -> v${e.latest} 升级完成`;
1225
+ case "auto-update-failed":
1226
+ case "auto-update-query-failed":
1227
+ case "auto-update-install-failed":
1228
+ return `${head} auto-update failed ${e.type} error=${e.error||""}`;
1151
1229
  default:
1152
1230
  return `${head} ${e?.type || "?"} ${JSON.stringify(e || {})}`;
1153
1231
  }
@@ -1167,22 +1245,35 @@ function npmCmd() {
1167
1245
  }
1168
1246
 
1169
1247
  function run(cmd, args, opts = {}) {
1248
+ const isWin = process.platform === "win32";
1170
1249
  return new Promise((resolve) => {
1171
- execFile(cmd, args, { timeout: 120_000, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
1250
+ // npm.cmd is a batch file on Windows execFile needs shell:true to find it via PATHEXT
1251
+ const execOpts = isWin ? { shell: true, windowsHide: true } : {};
1252
+ execFile(cmd, args, { timeout: 120_000, ...execOpts, ...opts }, (err, stdout, stderr) => resolve({ err, stdout, stderr }));
1172
1253
  });
1173
1254
  }
1174
1255
 
1175
1256
  async function updateSelf() {
1176
1257
  console.log(`mslxdff v${VERSION} — checking for updates…`);
1177
- const info = await run(npmCmd(), ["view", "mslxdff", "version", "dist-tags.latest"]);
1258
+ const info = await run(npmCmd(), ["view", "mslxdff", "dist-tags.latest", "--json"]);
1178
1259
  if (info.err) {
1179
- console.error(`could not query npm: ${info.err.message}`);
1260
+ console.error(`could not query npm: ${info.err.message || String(info.stderr || "").slice(0, 500)}`);
1180
1261
  process.exit(1);
1181
1262
  }
1182
- const [version, latest] = (info.stdout || "").trim().split(/\s+/);
1263
+ let latest = "";
1264
+ try {
1265
+ latest = JSON.parse(String(info.stdout || "").trim());
1266
+ if (Array.isArray(latest)) latest = latest[latest.length - 1];
1267
+ latest = String(latest || "").trim();
1268
+ } catch {
1269
+ const m = String(info.stdout || "").trim().match(/(\d+\.\d+\.\d+[^\s'"]*)/);
1270
+ latest = m ? m[1] : "";
1271
+ }
1272
+ latest = String(latest || "").replace(/['"]/g, "").trim();
1273
+ const version = VERSION;
1183
1274
  console.log(` installed: ${version}`);
1184
- console.log(` latest: ${latest}`);
1185
- if (version === latest) {
1275
+ console.log(` latest: ${latest || "unknown"}`);
1276
+ if (!latest || version === latest || compareSemver(latest, version) <= 0) {
1186
1277
  console.log("already up to date");
1187
1278
  process.exit(0);
1188
1279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auto.js CHANGED
@@ -132,9 +132,8 @@ export function createAutoSelector({
132
132
  slowCooldownMs,
133
133
  latencies,
134
134
  });
135
- if (inCooldown(requested, lastErrorAt, now(), cooldownMs, slowCooldownMs)) {
136
- return [...others, requested];
137
- }
135
+ // 显式指定模型:严格优先,永不因冷却被挤到最后(原设计:A deepseek 失败 B/D deepseek 并发 → 都失败才 fallback)
136
+ // 冷却仅影响 auto 的择优,不影响指定模型的“很难被更改”语义
138
137
  return [requested, ...others];
139
138
  }
140
139
 
package/src/routes.js CHANGED
@@ -193,6 +193,102 @@ function notFound(res) {
193
193
  return json(res, 404, { error: "Not Found" });
194
194
  }
195
195
 
196
+ // --- fallback 显式提示(巧妙不破兼容)---
197
+ // 机器可读:x-mslxdff-* headers;人类可读:mslxdff 字段 + SSE comment
198
+ function fallbackReason(lastErr) {
199
+ if (!lastErr) return "cooldown";
200
+ const s = Number(lastErr.status);
201
+ if (s === 429) return "rate_limited";
202
+ if (lastErr.message && /timeout/i.test(String(lastErr.message))) return "timeout";
203
+ if (s === 502 || s === 503 || s === 504) return "upstream_error";
204
+ if (s >= 400) return "upstream_error";
205
+ return "fallback";
206
+ }
207
+
208
+ function buildFallbackInfo({ requested, actual, lastErr, via, useAuto, lockModel }) {
209
+ if (!requested || !actual) return null;
210
+ const alwaysHeaders = {
211
+ requested_model: requested,
212
+ actual_model: actual,
213
+ via: via || "local",
214
+ };
215
+ // auto / lock 仍告知 actual,但不算 fallback
216
+ if (useAuto || lockModel) {
217
+ return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
218
+ }
219
+ const isFallback = requested !== actual;
220
+ if (!isFallback) {
221
+ return { ...alwaysHeaders, fallback: false, reason: null, notice: null };
222
+ }
223
+ const reason = fallbackReason(lastErr);
224
+ const reasonZh = reason === "rate_limited" ? "限流" : reason === "timeout" ? "超时" : reason === "cooldown" ? "冷却中" : "不可用";
225
+ const notice = `${requested} ${reasonZh},已由 ${actual} 代答`;
226
+ return { ...alwaysHeaders, fallback: true, reason, notice };
227
+ }
228
+
229
+ function applyFallbackHeaders(res, info) {
230
+ if (!info) return;
231
+ // 始终告知实际与请求,客户端对比即知
232
+ if (info.requested_model) res.setHeader("x-mslxdff-requested-model", info.requested_model);
233
+ if (info.actual_model) res.setHeader("x-mslxdff-actual-model", info.actual_model);
234
+ if (info.via) res.setHeader("x-mslxdff-via", info.via);
235
+ if (info.fallback) {
236
+ res.setHeader("x-mslxdff-fallback", "1");
237
+ if (info.reason) res.setHeader("x-mslxdff-fallback-reason", info.reason);
238
+ // 人类 curl 可见
239
+ if (info.notice) res.setHeader("x-mslxdff-notice", encodeURIComponent(info.notice));
240
+ }
241
+ }
242
+
243
+ function enrichNonStreamJson(obj, info) {
244
+ if (!info || typeof obj !== "object" || obj === null) return obj;
245
+ // 仅当 fallback 时才注入顶层 mslxdff,避免噪音;但始终可通过 header 拿到 actual
246
+ if (!info.fallback) return obj;
247
+ if (obj.mslxdff) return obj;
248
+ return {
249
+ ...obj,
250
+ mslxdff: {
251
+ fallback: true,
252
+ requested_model: info.requested_model,
253
+ actual_model: info.actual_model,
254
+ reason: info.reason,
255
+ via: info.via,
256
+ notice: info.notice,
257
+ },
258
+ };
259
+ }
260
+
261
+ function enrichSseChunkText(text, info) {
262
+ if (!info?.fallback) return text;
263
+ // 行级注入:对每行 data: {json} 尝试注入 mslxdff
264
+ const lines = text.split("\n");
265
+ let changed = false;
266
+ for (let i = 0; i < lines.length; i++) {
267
+ const line = lines[i];
268
+ const m = /^data:\s*(\{.*\})\s*$/.exec(line);
269
+ if (!m) continue;
270
+ try {
271
+ const obj = JSON.parse(m[1]);
272
+ if (obj && typeof obj === "object" && !obj.mslxdff) {
273
+ obj.mslxdff = {
274
+ fallback: true,
275
+ requested_model: info.requested_model,
276
+ actual_model: info.actual_model,
277
+ reason: info.reason,
278
+ via: info.via,
279
+ notice: info.notice,
280
+ };
281
+ lines[i] = `data: ${JSON.stringify(obj)}`;
282
+ changed = true;
283
+ break; // 仅注入首个 JSON 行
284
+ }
285
+ } catch {
286
+ continue;
287
+ }
288
+ }
289
+ return changed ? lines.join("\n") : text;
290
+ }
291
+
196
292
  function readBody(req) {
197
293
  return new Promise((resolve, reject) => {
198
294
  let data = "";
@@ -211,11 +307,12 @@ function readBody(req) {
211
307
  // Relay an upstream response to the client. Returns { status, ttfMs, aborted, interrupted, detail }
212
308
  // detail carries byte/chunk/sawDone diagnostics so a truncated deep-think
213
309
  // stream can be told apart from a clean EOF vs our stall/max vs client abort.
214
- async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS } = {}) {
310
+ async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, fallback } = {}) {
215
311
  const t0 = performance.now();
216
312
  const contentType = upRes.headers.get("content-type") || "";
217
313
  const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
218
314
  res.statusCode = upRes.status;
315
+ if (fallback) applyFallbackHeaders(res, fallback);
219
316
 
220
317
  let ttf = null;
221
318
  let interrupted = false;
@@ -246,6 +343,13 @@ async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, stream
246
343
  res.setHeader("Content-Type", "text/event-stream");
247
344
  res.setHeader("Cache-Control", "no-cache");
248
345
  res.setHeader("Connection", "keep-alive");
346
+ // SSE 注释:curl -N 可见,EventSource/SDK 自动忽略,不污染 content
347
+ if (fallback?.fallback) {
348
+ try {
349
+ res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
350
+ res.write(`: notice ${fallback.notice}\n\n`);
351
+ } catch {}
352
+ }
249
353
  if (upRes.body) {
250
354
  let first = true;
251
355
  let wroteAny = false;
@@ -301,10 +405,24 @@ async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, stream
301
405
  onFirstChunk?.(ttf);
302
406
  if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
303
407
  }
408
+ // 首块注入 mslxdff 字段(仅 fallback 时),SDK 解析 JSON 可直接发现
409
+ let outChunk = chunk;
410
+ if (first === false && fallback?.fallback && wroteAny === false) {
411
+ try {
412
+ let txt = "";
413
+ if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
414
+ else if (chunk instanceof Uint8Array) txt = Buffer.from(chunk).toString("utf8");
415
+ else if (typeof chunk === "string") txt = chunk;
416
+ if (txt.includes("data:")) {
417
+ const enriched = enrichSseChunkText(txt, fallback);
418
+ if (enriched !== txt) outChunk = Buffer.from(enriched, "utf8");
419
+ }
420
+ } catch {}
421
+ }
304
422
  wroteAny = true;
305
423
  detail.wroteChunks += 1;
306
- detail.wroteBytes += len;
307
- res.write(chunk);
424
+ detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
425
+ res.write(outChunk);
308
426
  armStall(); // no-op when STALL_TIMEOUT_MS=0; scoring uses SCORE_STALL_MS gap above
309
427
  }
310
428
  if (!detail.exitReason) detail.exitReason = "normal";
@@ -346,7 +464,9 @@ async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, stream
346
464
  detail.receivedBytes = Buffer.byteLength(text);
347
465
  detail.exitReason = "normal-non-stream";
348
466
  try {
349
- json(res, upRes.status, JSON.parse(text));
467
+ const parsed = JSON.parse(text);
468
+ const enriched = enrichNonStreamJson(parsed, fallback);
469
+ json(res, upRes.status, enriched);
350
470
  } catch {
351
471
  res.statusCode = upRes.status;
352
472
  res.setHeader("Content-Type", contentType || "text/plain");
@@ -428,17 +548,35 @@ async function forwardToPeer(peer, body, model, hops) {
428
548
  }
429
549
  }
430
550
 
431
- // Resolve the model a peer should serve for this request: reuse its hot-cache
432
- // model only when it matches the requested one; otherwise probe /v1/models/status
433
- // and prefer the requested model, falling back to the peer's first healthy one.
551
+ // Resolve the model a peer should serve for this request.
552
+ // 原设计严格语义:显式指定模型时,永远用该模型去试 peer,不因 peer 的本地 healthy 状态而偷换成 hy3。
553
+ // 只有 auto 模式才走 healthy 探测与择优。
434
554
  // Returns { peer, target } or null when the peer is unusable.
435
555
  async function resolvePeerTarget(ctx, peer) {
436
556
  const prevModel = ctx.peers.stat(peer.url)?.model;
437
557
  const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
438
558
  if (hot) return { peer, target: prevModel };
559
+ // 显式模型:严格用请求模型,不做 healthy 偷换(B/D 必须以 deepseek 去试,失败才算该模型在该 peer 不可用)
560
+ const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
561
+ if (isExplicit) {
562
+ // 仅做可达性探测:轻量 ping /v1/models/status 判断 peer 是否活着,不因模型状态过滤
563
+ const healthy = await peerHealthyModels(peer);
564
+ if (!healthy.length) {
565
+ // 无法探活也仍尝试:让 forward 去试,失败会由 race 逻辑记错;但为保持原有“全不健康则跳过”行为,仍标记
566
+ // 这里改为:即使 healthy 为空,也返回 target=ctx.model,让上游去判 429,而不是直接丢弃 peer
567
+ // 只有当 fetch 本身异常(healthy=[] 来自网络错)才视为 peer 不可用,需区分
568
+ // peerHealthyModels 在网络错时返回 [],此时应视为 peer 不可用
569
+ // 我们通过再次轻量探测区分:若 peer 完全不可达,healthy=[] 且 peer 曾无成功记录,则跳过
570
+ // 简化:若 healthy 为空,直接尝试目标模型,失败再记错(更符合“严格”)
571
+ ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
572
+ return { peer, target: ctx.model };
573
+ }
574
+ ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
575
+ return { peer, target: ctx.model };
576
+ }
577
+ // auto 模式:走原有择优逻辑
439
578
  const healthy = await peerHealthyModels(peer);
440
579
  if (!healthy.length) {
441
- // peer unreachable or every model unhealthy — mark it and move on
442
580
  await ctx.peers.recordError(peer.url);
443
581
  ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
444
582
  ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
@@ -647,8 +785,11 @@ const ROUTES = [
647
785
  }
648
786
  if (upRes) {
649
787
  logCall(model, upRes.status);
650
- evt("relay-start", { reqId, model, via: "local", isStream: Boolean(body.stream) });
788
+ const fallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "local", useAuto, lockModel });
789
+ if (fallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: fallback.reason, notice: fallback.notice, via: "local" });
790
+ evt("relay-start", { reqId, model, via: "local", isStream: Boolean(body.stream), fallback });
651
791
  const out = await relay(res, upRes, body, {
792
+ fallback,
652
793
  onFirstChunk: (delta) => {
653
794
  mark(`ttf-${model}`);
654
795
  evt("relay-first-chunk", { reqId, model, ttfMs: delta });
@@ -718,8 +859,11 @@ const ROUTES = [
718
859
  evt("peer-race-win", { reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
719
860
  await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
720
861
  logCall(win.target, win.res.status);
721
- evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream) });
862
+ const peerFallback = buildFallbackInfo({ requested, actual: win.target, lastErr, via: "peer", useAuto, lockModel });
863
+ if (peerFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: win.target, reason: peerFallback.reason, notice: peerFallback.notice, via: "peer" });
864
+ evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream), fallback: peerFallback });
722
865
  const out = await relay(res, win.res, body, {
866
+ fallback: peerFallback,
723
867
  onFirstChunk: (d) => mark(`ttf-peer-${win.target}`),
724
868
  onDownstreamAbort: () => evt("client-abort", { reqId, model: win.target, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
725
869
  });
@@ -746,8 +890,11 @@ const ROUTES = [
746
890
  const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
747
891
  if (isResponse) {
748
892
  // streaming response from leader's forward (which waited for broadband)
749
- evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group });
893
+ const bbFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
894
+ if (bbFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbFallback.reason, notice: bbFallback.notice, via: "broadband" });
895
+ evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group, fallback: bbFallback });
750
896
  const out = await relay(res, bb.result, body, {
897
+ fallback: bbFallback,
751
898
  onFirstChunk: (d) => mark(`ttf-bb-${model}`),
752
899
  onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
753
900
  });
@@ -779,8 +926,11 @@ const ROUTES = [
779
926
  return null;
780
927
  })(),
781
928
  };
782
- evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group });
929
+ const bbLocalFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
930
+ if (bbLocalFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbLocalFallback.reason, notice: bbLocalFallback.notice, via: "broadband" });
931
+ evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group, fallback: bbLocalFallback });
783
932
  const out = await relay(res, fakeRes, body, {
933
+ fallback: bbLocalFallback,
784
934
  onFirstChunk: (d) => mark(`ttf-bb-${model}`),
785
935
  onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
786
936
  });