mslxdff 0.1.227 → 0.1.228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.227",
3
+ "version": "0.1.228",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,7 +101,11 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
101
101
  }
102
102
  if (!order.length) order = [""];
103
103
  const canFallback = order.length > 1;
104
- const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
104
+ // hi 探针(x-mslxdff-probe:1)只问「这台机器此刻能不能打通上游」,不代表真实业务:
105
+ // 它绝不能再触发本机的组员/宽带接力——否则探针会跟着走完整条接力链(最坏 45s 预算),
106
+ // 而调用方只看它几百毫秒内活不活。组员侧就地直连,成败如实回给调用方。
107
+ const isProbe = String(req?.headers?.["x-mslxdff-probe"] || "") === "1";
108
+ const canForwardPeers = !isProbe && Boolean(peers) && hops < (maxHops ?? 3);
105
109
  mark("ordered");
106
110
 
107
111
  const reqIp = clientIp(req); // 一次解析两处用:request 事件的 ip 字段 + 回路语料的 clientIp(routes/helpers.js 取 x-forwarded-for 首段)
@@ -125,7 +129,7 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
125
129
  const clientSession = String(req?.headers?.["x-session-affinity"] || req?.headers?.["x-session-id"] || "").trim() || null;
126
130
  // triedUrls 挂请求级并**按引用**共享:同一请求内"同一组员不重复试"的契约跨候选生效
127
131
  // (peer 阶段各分支会 spread 出上下文副本,集合在这里建一次才不会各存一份)。
128
- const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set() };
132
+ const handlerCtx = { reqId, api, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid, sessionId: clientSession, clientIp: reqIp, triedUrls: new Set(), isProbe };
129
133
  if (clientSession) evt("client-session", { reqId, sessionId: clientSession.slice(0, 24) });
130
134
 
131
135
  await engine.run({
@@ -100,7 +100,9 @@ export async function runSerialTrial(ctx, deps = {}) {
100
100
  const chatOptsArg = Object.keys(chatOpts).length ? chatOpts : undefined;
101
101
  // 空转 200(模型无输出)同模型暂停重试:默认 3 次、阶梯 [2s,8s,30s];仅 EMPTY_MODEL_RESPONSE,
102
102
  // 429/403/500 与 fetch 异常走原有切号/failover(防烧额度)。MSLXDFF_EMPTY_TURN_RETRIES=0 关闭。
103
- const emptyCfg = emptyRetryCfg();
103
+ // 探针(x-mslxdff-probe:1)不重试:调用方只要一个「活不活」的答案,重试阶梯会把它吊 40s。
104
+ const isProbe = handlerCtx?.isProbe === true;
105
+ const emptyCfg = isProbe ? { max: 0, steps: null, delayMs: 0 } : emptyRetryCfg();
104
106
  const raiseCap = emptyRaiseCap();
105
107
  const nudgeCfg = emptyNudgeCfg();
106
108
  let emptyRetried = 0;
package/src/cli/format.js CHANGED
@@ -62,6 +62,8 @@ export function fmtEvent(e) {
62
62
  return `${head} peer check ${e.peer} -> ${e.count ? e.healthy.join(", ") : "no healthy models"}${e.strict ? " (strict)" : ""}`;
63
63
  case "peer-request":
64
64
  return `${head} peer req 并发发出 peer=${e.peer} model=${m(e.model)} hops=${e.hops}`;
65
+ case "peer-probe":
66
+ return `${head} peer hi 备选探活 peer=${e.peer} ${e.ok ? "ok" : `fail HTTP ${e.status ?? "network"}`} model=${m(e.model)} ${e.latencyMs ? `${e.latencyMs}ms` : ""}${e.action === "skip" ? " → 跳过换下一个" : ""}`;
65
67
  case "peer-forward":
66
68
  return `${head} peer resp 收到响应 peer=${e.peer} model=${m(e.model)} ${e.ok ? "ok" : "fail"} ${e.latencyMs ? `${e.latencyMs}ms` : ""} hops=${e.hops}${e.retry ? " (retry)" : ""}`;
67
69
  case "peer-error":
@@ -0,0 +1,53 @@
1
+ // 组员「打一个 hi」探针:备选时打一次、轮到它时再打一次,回不来就当它不行。
2
+ //
3
+ // 为什么要打 hi 而不是 /health:额度只体现在真实 chat 调用上。实测(2026-10-20,本机出口已知 429)
4
+ // GET /health 与 GET /zen/v1/models **都照样 200**,只有 POST chat 才回 429 FreeUsageLimitError。
5
+ // 为什么要挑在「备选」和「轮到」两个时刻:
6
+ // 备选是上一次切换时定的,中间上一个节点可能已在大 body 上撞墙几十秒(实测 1.5MB 撞 429 要
7
+ // 28~62s),那时有额度不代表现在还有 ⇒ 轮到它时必须再确认一次。
8
+ // 开销:hi + max_tokens=8,拿响应头即掐断,不等正文,也不触发对端空轮重试。
9
+ import { compatFetch, timeoutSignal } from "../compat.js";
10
+ import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
11
+
12
+ // 等 hi 的上限。超时按「没判过」处理(交主流程照常试),绝不因为探针慢就错杀一个能用的节点。
13
+ export function peerProbeTimeoutMs(env = process.env) {
14
+ const n = Number(env.MSLXDFF_PEER_PROBE_TIMEOUT_MS);
15
+ return Number.isInteger(n) && n > 0 ? n : 10_000;
16
+ }
17
+
18
+ /**
19
+ * 给一个组员打一句 hi,只关心它活不活。
20
+ * @returns {Promise<{ok: boolean, status: number, ms: number, unknown?: boolean}>} 永不抛
21
+ * ok=false 只在「明确不该再试」时给:拿到 4xx/5xx 响应。
22
+ * 连不上/超时一律 ok=true + unknown=true(那是主流程 forwardToPeer 的账,探针不越权判死)。
23
+ */
24
+ export async function probePeer(peer, { model, timeoutMs = peerProbeTimeoutMs(), dispatcher, fetchImpl = compatFetch } = {}) {
25
+ const t0 = Date.now();
26
+ if (!peer?.url || !model) return { ok: true, status: 0, ms: 0, unknown: true };
27
+ const headers = {
28
+ "Content-Type": "application/json",
29
+ "Authorization": `Bearer ${peer.token || ""}`,
30
+ "x-mslxdff-hops": "1",
31
+ "x-mslxdff-model-lock": model,
32
+ // 探针必须可识别:组员侧据此知道这是「体检」不是真实业务(不落 usage、不参与空轮重试),
33
+ // 日志/测试也能把它与真实转发分开,否则「一次请求打了几次组员」这类断言会被探针污染。
34
+ "x-mslxdff-probe": "1",
35
+ "Accept": "text/event-stream",
36
+ };
37
+ const share = buildShareKeysHeader(model);
38
+ if (share) headers[SHARE_KEYS_HEADER] = share;
39
+ try {
40
+ const res = await fetchImpl(`${String(peer.url).replace(/\/+$/, "")}/v1/chat/completions`, {
41
+ method: "POST",
42
+ headers,
43
+ body: JSON.stringify({ model, stream: true, max_tokens: 8, messages: [{ role: "user", content: "hi" }] }),
44
+ signal: timeoutSignal(timeoutMs),
45
+ ...(dispatcher ? { dispatcher } : {}),
46
+ });
47
+ // 拿到响应头就够判了:立刻掐断,不等正文
48
+ try { const c = res.body?.cancel?.(); if (c && typeof c.catch === "function") c.catch(() => {}); } catch {}
49
+ return { ok: res.status < 400, status: res.status, ms: Date.now() - t0 };
50
+ } catch {
51
+ return { ok: true, status: 0, ms: Date.now() - t0, unknown: true };
52
+ }
53
+ }
@@ -45,7 +45,7 @@ export function warmPeer(peer, deps = {}) {
45
45
  export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried, deps }) {
46
46
  const {
47
47
  resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
48
- perAttemptCap, deadline, budgetMs,
48
+ perAttemptCap, deadline, budgetMs, probePeer,
49
49
  } = deps;
50
50
  const warm = deps.warmPeer || warmPeer;
51
51
  const failed = [];
@@ -71,22 +71,51 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
71
71
  const ipv4Chain = filterSlow(fresh(candidates));
72
72
  const ipv6Chain = filterSlow(fresh(v6Candidates));
73
73
 
74
- // —— 预取:进循环前就把「下一个」定好,并顺手预热连接 ——
74
+ // —— 预取 + 探针(用户 2026-10-20 口径)——
75
+ // 「某个节点当备选的时候打一个 hi,准备轮到它了也打一个 hi;hi 都回不来就当它不行,直接换。」
76
+ // 两个 hi 都打,不做结果复用:备选是上一次切换时定的,中间可能已过了几十秒
77
+ // (上一个节点在大 body 上撞墙,实测 1.5MB 要 28~62s),那时活不代表现在活。
78
+ // 备选那个 hi 的作用是暖连接 + 提前暴露死节点,轮到时的那个才决定去留。
75
79
  let pending = null;
76
- const prime = (peer) => {
80
+ // resolvePeerTarget 会打 /v1/models/status,别为探针白测两次:这里记住解析结果给下一轮复用。
81
+ const resolved = new Map(); // url -> Promise<{peer,target}|null>
82
+ const resolveOnce = (peer) => {
83
+ const hit = resolved.get(peer.url);
84
+ if (hit) return hit;
85
+ const p = Promise.resolve()
86
+ .then(() => resolvePeerTarget(ctx, peer))
87
+ .catch(() => null);
88
+ resolved.set(peer.url, p);
89
+ return p;
90
+ };
91
+ const prime = (peer, target) => {
92
+ if (!peer || !target || typeof probePeer !== "function") return;
93
+ try {
94
+ const p = probePeer(peer, target);
95
+ if (p && typeof p.then === "function") p.catch(() => {});
96
+ } catch { /* 预热失败不影响主流程 */ }
97
+ };
98
+ const primeAt = (peer) => {
77
99
  if (!peer) return;
78
- warm(peer, deps.warmPeerDeps);
100
+ resolveOnce(peer)
101
+ .then((r) => { if (r) prime(r.peer, r.target); })
102
+ .catch(() => {});
79
103
  };
80
104
  const takeNext = (stage) => {
81
105
  const chain = stage === "ipv6" ? ipv6Chain : ipv4Chain;
82
106
  const p = chain.shift() || null;
83
- if (p) prime(chain[0] || null); // 立即预热「再下一个」,保证永远有备选就绪
107
+ // 立刻给「再下一个」打 hi。模型名要现在解析——下一轮循环该它时 ctx.model 可能已变。
108
+ if (p) primeAt(chain[0] || null);
84
109
  return p;
85
110
  };
86
111
 
87
112
  let stage = "ipv4";
88
113
  let emptyIpv4Announced = false;
89
114
 
115
+ // 链头从没当过「备选」(它一上来就是主角),进入循环前先给它也打一个 hi ——
116
+ // 此时 ctx.model 还是本机刚失败的那个模型,解析目标与后面轮到时同源。
117
+ primeAt(ipv4Chain[0] || null);
118
+
90
119
  for (;;) {
91
120
  if (deadline > 0 && Date.now() >= deadline) {
92
121
  const remaining = ipv4Chain.length + ipv6Chain.length;
@@ -110,12 +139,33 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
110
139
  }
111
140
  if (!pending) break;
112
141
 
113
- const prepared = await resolvePeerTarget(ctx, pending);
142
+ const prepared = await resolveOnce(pending);
114
143
  if (!prepared) continue;
115
144
  const { peer, target } = prepared;
116
145
  tried.add(peer.url);
117
146
  const ctrl = new AbortController();
118
147
  const name = peerName(peer);
148
+
149
+ // 轮到它了:再打一个 hi。回不来就当这个备选不行,直接换下一个。
150
+ if (typeof probePeer === "function") {
151
+ const t = performance.now();
152
+ let r;
153
+ try { r = await probePeer(peer, target); } catch { r = { ok: false }; }
154
+ const probeMs = Math.round(performance.now() - t);
155
+ if (r && r.ok === false) {
156
+ ctx.evt("peer-probe", { peer: peer.url, name, model: target, phase: "turn", ok: false, status: r.status ?? null, latencyMs: probeMs, action: "skip" });
157
+ failed.push({ ok: false, peer, target, res: null, status: r.status || 502 });
158
+ recordLater(() => ctx.peers.recordError(peer.url, { status: r.status || 502 }));
159
+ recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
160
+ continue;
161
+ }
162
+ ctx.evt("peer-probe", { peer: peer.url, name, model: target, phase: "turn", ok: true, status: r?.status ?? null, latencyMs: probeMs });
163
+ if (deadline > 0 && Date.now() >= deadline) {
164
+ ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽(探针后)`, budgetMs, remaining: ipv4Chain.length + ipv6Chain.length });
165
+ break;
166
+ }
167
+ }
168
+
119
169
  ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body), serial: 1, stage });
120
170
  if (ctx.plugins?.length) {
121
171
  runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
@@ -5,6 +5,7 @@ import { runHook } from "../plugins.js";
5
5
  import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
6
6
  import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
7
7
  import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
8
+ import { probePeer, peerProbeTimeoutMs } from "./peer-probe.js";
8
9
  import { overForwardLimit, contextGuardMessage } from "../chat-pipeline/context-guard.js";
9
10
 
10
11
  // peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
@@ -252,6 +253,13 @@ export async function racePeerCandidates(candidates, ctx) {
252
253
  perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs,
253
254
  // 连接预热:对「下一个备选」提前打 /health,复用 chat 的同一连接池(见 warmPeerHealth 注释)
254
255
  warmPeer: (peer) => { if (peer?.url) warmPeerHealth(peer.url, { timeoutMs: 1500 }).catch(() => {}); },
256
+ // 用户 2026-10-20:备选时打一个 hi、轮到它时再打一个 hi,hi 回不来就换下一个。
257
+ // 走 getPeerDispatcher 的**同一条连接池**(与 chat 转发复用 socket,裸 fetch 热的是另一条等于白热)。
258
+ probePeer: (peer, model) => probePeer(peer, {
259
+ model: model || null,
260
+ timeoutMs: peerProbeTimeoutMs(),
261
+ dispatcher: getPeerDispatcher(),
262
+ }),
255
263
  },
256
264
  });
257
265
  }