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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(() => {});
|
package/src/routes/peers.js
CHANGED
|
@@ -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
|
}
|