mslxdff 0.1.35 → 0.1.37

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
@@ -1179,17 +1179,42 @@ function fmtEvent(e) {
1179
1179
  const t = e?.ts ? new Date(e.ts).toISOString().slice(11, 19) : "--:--:--";
1180
1180
  const head = `[${t}]`;
1181
1181
  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})` : "";
1182
1183
  switch (e?.type) {
1183
1184
  case "request":
1184
- return `${head} request ${m(e.model)}${e.auto ? " (auto)" : ""} hops=${e.hops} from ${e.ip || "?"}${e.stream ? " stream" : ""}${e.prompt ? ` content="${e.prompt}"` : ""}`;
1185
+ 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 || ""}`;
1186
+ case "client-request":
1187
+ return `${head} client req 客户端请求 model=${m(e.requested)} raw=${m(e.rawModel)} lock=${m(e.lockModel)} ip=${e.ip || "?"}${e.stream ? " stream" : ""}`;
1188
+ case "ordered":
1189
+ return `${head} ordered 尝试顺序 [${(e.order||[]).join(" -> ")}] canFallback=${e.canFallback} useAuto=${e.useAuto}`;
1190
+ case "model-try":
1191
+ return `${head} model-try 尝试本地 model=${m(e.model)} idx=${e.idx} reqId=${e.reqId || ""}`;
1192
+ case "upstream-try":
1193
+ return `${head} upstream try 上游请求 model=${m(e.model)} attempt=${e.attempt || 1}`;
1194
+ case "upstream-done":
1195
+ return `${head} upstream ok ${m(e.model)} HTTP ${e.status}${e.timing ? ` total=${e.timing.totalMs}ms` : ""}`;
1185
1196
  case "upstream-error":
1186
1197
  return `${head} upstream err ${m(e.model)} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
1198
+ case "peer-race-start":
1199
+ return `${head} peer race 开始并发给组员 model=${m(e.model)} peers=${e.peers}`;
1187
1200
  case "peer-health":
1188
- return `${head} peer check ${e.peer} -> ${e.count ? e.healthy.join(", ") : "no healthy models"}`;
1201
+ return `${head} peer check ${e.peer} -> ${e.count ? e.healthy.join(", ") : "no healthy models"}${e.strict ? " (strict)" : ""}`;
1189
1202
  case "peer-forward":
1190
- return `${head} forward -> ${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
1203
+ return `${head} peer req 给组员请求 peer=${e.peer} model=${m(e.model)} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
1191
1204
  case "peer-error":
1192
- return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)}`;
1205
+ return `${head} peer err ${e.peer} ${e.status ? `HTTP ${e.status}` : "network"}: ${m(e.message)} model=${m(e.model)}`;
1206
+ case "peer-race-win":
1207
+ return `${head} peer win 选中 peer=${e.winPeer} model=${m(e.winTarget)} latency=${e.latencyMs}ms`;
1208
+ case "peer-race-lose":
1209
+ return `${head} peer lose 组员全部失败 model=${m(e.model)}`;
1210
+ case "fallback":
1211
+ return `${head} fallback ${m(e.from)} -> ${m(e.to)} reason=${m(e.reason)}`;
1212
+ case "fallback-notice":
1213
+ return `${head} fallback! 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} 原因=${m(e.reason)} via=${m(e.via)} notice=${m(e.notice)}`;
1214
+ case "relay-try":
1215
+ return `${head} relay try 给宽带中继请求 target=${e.target} model=${m(e.model)} via=${e.via}`;
1216
+ case "relay-fail":
1217
+ return `${head} relay fail ${e.target} ${e.status ? `HTTP ${e.status}` : ""} ${m(e.message)}`;
1193
1218
  case "relay-ip-change":
1194
1219
  return `${head} relay ip ${e.member || e.id || "?"} ${e.oldIp || "?"} -> ${e.newIp || e.publicIp || "?"} via leader`;
1195
1220
  case "relay-forward":
@@ -1199,7 +1224,9 @@ function fmtEvent(e) {
1199
1224
  case "client-abort":
1200
1225
  return `${head} client abort ${m(e.model)} total=${fmtDur(e.totalMs)}`;
1201
1226
  case "result":
1202
- return `${head} result ${e.status} ${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}`;
1227
+ return `${head} result 返回客户端 status=${e.status} model=${m(e.model)} via=${e.via} 响应耗时 ${fmtDur(e.durationMs)}${fallbackTag}`;
1228
+ case "client-response":
1229
+ 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}`;
1203
1230
  case "auto-update-enabled":
1204
1231
  return `${head} auto-update enabled every ${Math.round((e.intervalMs||0)/60000)}m current=${e.current}`;
1205
1232
  case "auto-update-disabled":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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 });
@@ -674,7 +815,8 @@ const ROUTES = [
674
815
  }
675
816
  evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
676
817
  logCall(model, 200);
677
- evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null });
818
+ 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 });
819
+ evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, interrupted: true, reqId });
678
820
  return;
679
821
  }
680
822
  const elapsed = Date.now() - startedAt;
@@ -700,7 +842,8 @@ const ROUTES = [
700
842
  } else if (scoredSlow && out.detail) {
701
843
  // already recorded slow+latency above, still ensure latency EMA is updated for slow case (done)
702
844
  }
703
- evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
845
+ 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 });
846
+ evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, reqId });
704
847
  return;
705
848
  }
706
849
 
@@ -718,8 +861,11 @@ const ROUTES = [
718
861
  evt("peer-race-win", { reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs });
719
862
  await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
720
863
  logCall(win.target, win.res.status);
721
- evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream) });
864
+ const peerFallback = buildFallbackInfo({ requested, actual: win.target, lastErr, via: "peer", useAuto, lockModel });
865
+ if (peerFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: win.target, reason: peerFallback.reason, notice: peerFallback.notice, via: "peer" });
866
+ evt("relay-start", { reqId, model: win.target, via: "peer", isStream: Boolean(body.stream), fallback: peerFallback });
722
867
  const out = await relay(res, win.res, body, {
868
+ fallback: peerFallback,
723
869
  onFirstChunk: (d) => mark(`ttf-peer-${win.target}`),
724
870
  onDownstreamAbort: () => evt("client-abort", { reqId, model: win.target, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
725
871
  });
@@ -733,7 +879,8 @@ const ROUTES = [
733
879
  await auto.recordOk(win.target, { latencyMs });
734
880
  }
735
881
  }
736
- 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 });
882
+ 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 });
883
+ evt("client-response", { requested, actual: win.target, via: "peer", fallback: peerFallback, status: out.status, reqId });
737
884
  return;
738
885
  }
739
886
  evt("peer-race-lose", { reqId, model });
@@ -746,8 +893,11 @@ const ROUTES = [
746
893
  const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
747
894
  if (isResponse) {
748
895
  // streaming response from leader's forward (which waited for broadband)
749
- evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group });
896
+ const bbFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
897
+ if (bbFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbFallback.reason, notice: bbFallback.notice, via: "broadband" });
898
+ evt("relay-start", { reqId, model, via: "broadband", target: bb.target, group: bb.group, fallback: bbFallback });
750
899
  const out = await relay(res, bb.result, body, {
900
+ fallback: bbFallback,
751
901
  onFirstChunk: (d) => mark(`ttf-bb-${model}`),
752
902
  onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
753
903
  });
@@ -761,7 +911,8 @@ const ROUTES = [
761
911
  await auto.recordOk(model, { latencyMs });
762
912
  }
763
913
  }
764
- evt("result", { model, status: out.status, via: "broadband", timing: bb.result._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
914
+ 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 });
915
+ evt("client-response", { requested, actual: model, via: "broadband", fallback: bbFallback, status: out.status, reqId });
765
916
  return;
766
917
  } else if (bb.result && typeof bb.result.status === "number") {
767
918
  // buffered result from local leader enqueue
@@ -779,13 +930,17 @@ const ROUTES = [
779
930
  return null;
780
931
  })(),
781
932
  };
782
- evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group });
933
+ const bbLocalFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
934
+ if (bbLocalFallback?.fallback) evt("fallback-notice", { reqId, requested, actual: model, reason: bbLocalFallback.reason, notice: bbLocalFallback.notice, via: "broadband" });
935
+ evt("relay-start", { reqId, model, via: "broadband-local", target: bb.target, group: bb.group, fallback: bbLocalFallback });
783
936
  const out = await relay(res, fakeRes, body, {
937
+ fallback: bbLocalFallback,
784
938
  onFirstChunk: (d) => mark(`ttf-bb-${model}`),
785
939
  onDownstreamAbort: () => evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
786
940
  });
787
941
  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 });
788
- evt("result", { model, status: out.status, via: "broadband", timing: null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
942
+ 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 });
943
+ evt("client-response", { requested, actual: model, via: "broadband", fallback: bbLocalFallback, status: out.status, reqId });
789
944
  return;
790
945
  }
791
946
  }