mslxdff 0.1.213 → 0.1.215
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 +1 -1
- package/src/cli/format.js +10 -0
- package/src/groups.js +4 -1
- package/src/model-trace.js +22 -0
- package/src/peers.js +20 -10
- package/src/routes/groups-relay.js +50 -10
- package/src/routes/peer-serial.js +99 -0
- package/src/routes/peers.js +23 -0
- package/src/routes/relay-queue.js +4 -1
- package/src/runtime/broadband.js +16 -1
- package/src/upstream-engine/index.js +16 -4
- package/src/upstream.js +14 -4
package/package.json
CHANGED
package/src/cli/format.js
CHANGED
|
@@ -70,12 +70,22 @@ export function fmtEvent(e) {
|
|
|
70
70
|
return `${head} peer win 选中 peer=${e.winPeer} model=${m(e.winTarget)} latency=${e.latencyMs}ms`;
|
|
71
71
|
case "peer-race-lose":
|
|
72
72
|
return `${head} peer lose 组员全部失败 model=${m(e.model)}`;
|
|
73
|
+
case "peer-skip-slow":
|
|
74
|
+
return `${head} peer skip 跳过过慢组员 peer=${e.peer} 历史延迟=${e.latencyMs ?? "-"}ms > ${e.capMs ?? "-"}ms`;
|
|
73
75
|
case "fallback":
|
|
74
76
|
return `${head} fallback ${m(e.from)} -> ${m(e.to)} reason=${m(e.reason)}`;
|
|
75
77
|
case "fallback-notice":
|
|
76
78
|
return `${head} fallback! 客户端请求 ${m(e.requested)} 实际返回 ${m(e.actual)} 原因=${m(e.reason)} via=${m(e.via)} notice=${m(e.notice)}`;
|
|
77
79
|
case "relay-try":
|
|
78
80
|
return `${head} relay try 给宽带中继请求 target=${e.target} model=${m(e.model)} via=${e.via}`;
|
|
81
|
+
case "relay-miss":
|
|
82
|
+
return `${head} relay miss 宽带中继无候选(${e.reason || "-"}${e.candidates != null ? ` candidates=${e.candidates}` : ""})`;
|
|
83
|
+
case "relay-received":
|
|
84
|
+
return `${head} relay recv ${e.via === "broadband" ? "经中继" : "直连"}转给 target=${e.target} group=${e.group} model=${m(e.model)} msgs=${e.messages ?? "-"} tools=${e.tools ?? "-"} maxTok=${e.maxTokens ?? "-"}`;
|
|
85
|
+
case "relay-queued":
|
|
86
|
+
return `${head} relay queued 已入队交成员 target=${e.target} reqId=${e.reqId || "-"}`;
|
|
87
|
+
case "relay-member-result":
|
|
88
|
+
return `${head} relay result 成员回 target=${e.target} ${e.ok ? "ok" : "FAIL"} ${e.status ? `HTTP ${e.status}` : ""} ${e.ms ? `${e.ms}ms` : ""}${e.respChars ? ` resp=${e.respChars}B` : ""} ${m(e.error || e.respBody || "")}`;
|
|
79
89
|
case "relay-fail":
|
|
80
90
|
return `${head} relay fail ${e.target} ${e.status ? `HTTP ${e.status}` : ""} ${m(e.message)}`;
|
|
81
91
|
case "relay-ip-change":
|
package/src/groups.js
CHANGED
|
@@ -202,7 +202,10 @@ export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [
|
|
|
202
202
|
if (kind === "broadband" || String(m?.url || "").startsWith("relay://")) continue;
|
|
203
203
|
const url = normalizePeerUrl(m?.url);
|
|
204
204
|
if (!url || url === self) continue;
|
|
205
|
-
|
|
205
|
+
// heartbeatLatencyMs:组长在**心跳**里量的成员延迟(成员上报 rttMs)。选路 `ordered()` 在没有
|
|
206
|
+
// 真实请求 EMA 时回落到它——这就是「组员定时上报的过程中就知道谁延迟更低」。
|
|
207
|
+
const hbLat = Number.isFinite(Number(m?.latencyMs)) && Number(m.latencyMs) >= 0 ? Number(m.latencyMs) : null;
|
|
208
|
+
if (peers.add({ name: id, url, token: m?.token || "", group, ...(hbLat != null ? { heartbeatLatencyMs: hbLat } : {}) })) added++;
|
|
206
209
|
}
|
|
207
210
|
return { removed, added, total: Object.keys(members || {}).length };
|
|
208
211
|
}
|
package/src/model-trace.js
CHANGED
|
@@ -87,6 +87,10 @@ const DECISION_FIELDS = [
|
|
|
87
87
|
"heldOpen", "terminalForm", "requestWaitedMs", "budgetMs", "budgetOut",
|
|
88
88
|
// 终局判决(ADR-0058):取证时第一眼要看的三件——判的哪种、命中哪条判据、是否因此没问组员
|
|
89
89
|
"kind", "marker", "peersSkipped",
|
|
90
|
+
// 中继/组员目标(2026-10-19):跨机链路排障第一眼要的是**这一跳打给了谁**——target= 目标成员
|
|
91
|
+
// (url 或 relay://id)、group= 组、leaderUrl= 经谁中继;缺了这几项,relay-try/fail 只剩状态码,
|
|
92
|
+
// 看日志根本认不出是哪个组员出的问题(用户实证要求「日志要全面」)。
|
|
93
|
+
"target", "group", "leaderUrl", "candidates", "tried",
|
|
90
94
|
];
|
|
91
95
|
|
|
92
96
|
function decisionKv(data = {}) {
|
|
@@ -141,6 +145,24 @@ export function formatModelTrace({ type, reqId, model, data = {}, request = null
|
|
|
141
145
|
detail = `upstream_response status=${data.status ?? "-"} finish=${d.sawFinishReason || "-"} chunks=${d.receivedChunks ?? "-"} bytes=${d.receivedBytes ?? "-"} tools=${d.toolCalls ?? 0} chars=${d.chars ?? 0} ${usageTxt} elapsed=${data.totalMs ?? "-"}ms${data.upstream ? ` upstream=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
|
|
142
146
|
} else if (type === "result" || type === "client-response") {
|
|
143
147
|
detail = `client status=${data.status ?? "-"} via=${data.via || "-"} actual=${data.actual || model || "-"} fallback=${data.fallback?.fallback ? 1 : 0} total=${totalMs ?? data.durationMs ?? "-"}ms${data.upstream ? ` upstream=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
|
|
148
|
+
} else if (type === "relay-try") {
|
|
149
|
+
detail = `指定宽带中继 target=${data.target || "?"} group=${data.group || "-"} via=${data.via || "-"}`;
|
|
150
|
+
} else if (type === "relay-received") {
|
|
151
|
+
detail = `中继收到 转给 target=${data.target || "?"} group=${data.group || "-"} via=${data.via || "-"} msgs=${data.messages ?? "-"} tools=${data.tools ?? "-"} maxTok=${data.maxTokens ?? "-"}`;
|
|
152
|
+
} else if (type === "relay-queued") {
|
|
153
|
+
detail = `已入队交成员 target=${data.target || "?"} reqId=${data.reqId || "-"}`;
|
|
154
|
+
} else if (type === "relay-member-result") {
|
|
155
|
+
detail = `成员回 target=${data.target || "?"} ${data.ok ? "ok" : "FAIL"}${data.status ? ` status=${data.status}` : ""}${data.ms ? ` ${data.ms}ms` : ""}${data.respChars ? ` resp=${data.respChars}B` : ""} ${safeText(data.error || data.respBody || "", 200)}`;
|
|
156
|
+
} else if (type === "relay-fail") {
|
|
157
|
+
detail = `中继失败 target=${data.target || "?"} group=${data.group || "-"} via=${data.via || "-"}${data.status ? ` status=${data.status}` : ""} ${safeText(data.message || "")}`;
|
|
158
|
+
} else if (type === "relay-miss") {
|
|
159
|
+
detail = `宽带中继无候选可试(全部候选未通/无存活成员,交回本地兜底)`;
|
|
160
|
+
} else if (type === "peer-race-start") {
|
|
161
|
+
detail = `组员竞速开始 peers=${data.peers ?? "-"}`;
|
|
162
|
+
} else if (type === "peer-budget-out") {
|
|
163
|
+
detail = `组员竞速预算用尽 reason=${safeText(data.reason || "", 80)} budgetMs=${data.budgetMs ?? "-"} remaining=${data.remaining ?? "-"}`;
|
|
164
|
+
} else if (type === "peer-skip-slow") {
|
|
165
|
+
detail = `跳过过慢组员 peer=${data.peer || "?"} 历史延迟=${data.latencyMs ?? "-"}ms > 上限=${data.capMs ?? "-"}ms`;
|
|
144
166
|
} else detail = safeText(`${decisionKv(data)} ${data.message || data.error || ""}`, 200);
|
|
145
167
|
return `[${base.time}] [req=${base.req}] [model=${base.model}] [stage=${base.stage}] ${detail}`;
|
|
146
168
|
}
|
package/src/peers.js
CHANGED
|
@@ -138,22 +138,32 @@ export function createPeersService({
|
|
|
138
138
|
return hot * 1_000_000_000 + latency * 1_000 + fails;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
141
|
+
// 可用组员排序(用户 2026-10-19 决定):**延迟最小优先**,不再「热优先」。
|
|
142
|
+
// 语义 = 「组员的额度用完后,用跟组长延迟最小的组员」——延迟是首要判据。
|
|
143
|
+
// 延迟来源(优先级从高到低):
|
|
144
|
+
// ① stats 里的真实请求 EMA(recordResult 记的 latencyMs)——最可信;
|
|
145
|
+
// ② peer.heartbeatLatencyMs —— 组员**定时心跳**上报的 rtt(没跑过真实请求时也能排序);
|
|
146
|
+
// 未测过延迟的排在有数据之后;同延迟按「最早失败优先」;都没失败过的排最后。
|
|
147
|
+
function peerLatency(p) {
|
|
148
|
+
const s = stats[p.url]?.latencyMs;
|
|
149
|
+
if (s != null) return s;
|
|
150
|
+
const hb = list.find((x) => x.url === p.url)?.heartbeatLatencyMs;
|
|
151
|
+
return Number.isFinite(Number(hb)) ? Number(hb) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
function ordered(t = now()) {
|
|
147
155
|
return available().sort((a, b) => {
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
156
|
+
const la = peerLatency(a);
|
|
157
|
+
const lb = peerLatency(b);
|
|
158
|
+
const ha = la != null, hb = lb != null;
|
|
159
|
+
if (ha !== hb) return ha ? -1 : 1; // 有延迟数据的优先
|
|
160
|
+
if (ha && hb && la !== lb) return la - lb; // 延迟小的先上
|
|
151
161
|
const ea = lastErrorAt[a.url];
|
|
152
162
|
const eb = lastErrorAt[b.url];
|
|
153
163
|
if (ea == null && eb == null) return 0;
|
|
154
|
-
if (ea == null) return 1;
|
|
164
|
+
if (ea == null) return 1;
|
|
155
165
|
if (eb == null) return -1;
|
|
156
|
-
return ea - eb;
|
|
166
|
+
return ea - eb;
|
|
157
167
|
});
|
|
158
168
|
}
|
|
159
169
|
|
|
@@ -3,7 +3,22 @@ import { DEFAULT_MAX_HOPS } from "../peers.js";
|
|
|
3
3
|
import { enqueueRelay, dequeueRelayForPoll, resolveRelay, subscribeStream, unsubscribeStream } from "./relay-queue.js";
|
|
4
4
|
import { compatFetch } from "../compat.js";
|
|
5
5
|
|
|
6
|
+
/** 请求规模摘要(给中继日志用):消息数/角色分布/工具数/maxTokens/model —— 排障第一眼要看清"转了什么"。 */
|
|
7
|
+
function relayReqSummary(b = {}) {
|
|
8
|
+
const msgs = Array.isArray(b?.messages) ? b.messages : [];
|
|
9
|
+
const roles = {};
|
|
10
|
+
for (const m of msgs) roles[String(m?.role || "?")] = (roles[String(m?.role || "?")] || 0) + 1;
|
|
11
|
+
return { model: b?.model || "-", stream: b?.stream === true, messages: msgs.length, roles, tools: Array.isArray(b?.tools) ? b.tools.length : 0, maxTokens: Number(b?.max_tokens ?? b?.max_completion_tokens) || null };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 响应正文摘要(供中继日志):单行化 + 截断,只为让人看出上游回的是什么。 */
|
|
15
|
+
function snippet(v, n = 300) {
|
|
16
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
17
|
+
return String(s || "").replace(/\s+/g, " ").slice(0, n);
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
21
|
+
const tRecv = Date.now();
|
|
7
22
|
const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
|
|
8
23
|
if (!auth) return json(res, 401, { error: "bearer token required" });
|
|
9
24
|
let body;
|
|
@@ -22,9 +37,19 @@ export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
|
22
37
|
if (m) {
|
|
23
38
|
m.publicIp = ip;
|
|
24
39
|
m.lastSeen = Date.now();
|
|
25
|
-
|
|
40
|
+
// 延迟采样(心跳即可得,无需真实请求):
|
|
41
|
+
// rttMs = 成员自己量的「上一轮心跳往返」(它到组长的网络路径)
|
|
42
|
+
// serverMs = 组长这一侧从收到到处理完的耗时
|
|
43
|
+
// 两者取有值的那个写进成员记录,供选路按「延迟最小优先」排序(routes/peers.js ordered)。
|
|
44
|
+
const reportedRtt = Number(body?.rttMs);
|
|
45
|
+
const memberRtt = Number.isFinite(reportedRtt) && reportedRtt >= 0 ? Math.round(reportedRtt) : null;
|
|
46
|
+
if (memberRtt != null) m.latencyMs = memberRtt;
|
|
47
|
+
try {
|
|
48
|
+
groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen, ...(memberRtt != null ? { latencyMs: memberRtt } : {}) });
|
|
49
|
+
} catch {}
|
|
26
50
|
}
|
|
27
|
-
const
|
|
51
|
+
const serverMs = Date.now() - tRecv;
|
|
52
|
+
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, rttMs: Number.isFinite(Number(body?.rttMs)) ? Number(body.rttMs) : null, serverMs, group: groupName };
|
|
28
53
|
if (bus) bus.emit(evtData);
|
|
29
54
|
logs?.appendEvent?.(evtData);
|
|
30
55
|
}
|
|
@@ -169,10 +194,15 @@ export async function forwardHandler({ req, res, groups, bus, logs, token }) {
|
|
|
169
194
|
const targetMember = Object.values(members).find((m) => m.url === target) || Object.entries(members).find(([id]) => id === target)?.[1];
|
|
170
195
|
if (!targetMember) return json(res, 404, { error: `target ${target} not found in group ${groupName}` });
|
|
171
196
|
const isBb = targetMember.kind === "broadband" || String(targetMember.url).startsWith("relay://");
|
|
197
|
+
const reqSummary = relayReqSummary(body.body || body);
|
|
198
|
+
const fwdEvt = (type, extra) => { const e = { ts: Date.now(), type, group: groupName, target, hops, ...extra }; try { logs?.appendEvent?.(e); } catch {} try { bus?.emit?.(e); } catch {} };
|
|
199
|
+
// 组长「收到」:谁让我转给谁、转的是什么规模 —— 中继全流程的第一条日志
|
|
200
|
+
fwdEvt("relay-received", { via: isBb ? "broadband" : "direct", reqId: body.reqId || null, ...reqSummary });
|
|
172
201
|
if (!isBb) {
|
|
173
202
|
// 标记走直连:**在 fetch 之前**就设,这样直连失败(catch 里的 502)也带着它——
|
|
174
203
|
// 那正是最需要区分的一跳(组长直连该成员不通 vs 宽带成员回报失败,两者的 body 可能一模一样)。
|
|
175
204
|
res.setHeader("x-mslxdff-relay-via", "direct");
|
|
205
|
+
const t0 = Date.now();
|
|
176
206
|
try {
|
|
177
207
|
const fwdBody = body.body || body;
|
|
178
208
|
const r = await compatFetch(`${targetMember.url}/v1/chat/completions`, {
|
|
@@ -180,41 +210,49 @@ export async function forwardHandler({ req, res, groups, bus, logs, token }) {
|
|
|
180
210
|
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" },
|
|
181
211
|
body: JSON.stringify(fwdBody),
|
|
182
212
|
});
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
213
|
+
const isSSE = r.headers.get("content-type")?.includes("text/event-stream");
|
|
214
|
+
if (isSSE) {
|
|
215
|
+
// 流式直连:没法先读全 body 再转发,就记「已建立」+ 状态码
|
|
216
|
+
fwdEvt("relay-forward", { via: "direct", ok: r.status < 400, status: r.status, ms: Date.now() - t0, detail: "stream 直连建立" });
|
|
217
|
+
res.statusCode = r.status;
|
|
188
218
|
res.setHeader("Content-Type", "text/event-stream");
|
|
189
219
|
if (r.body) for await (const c of r.body) res.write(c);
|
|
190
220
|
res.end();
|
|
191
221
|
} else {
|
|
192
222
|
const txt = await r.text();
|
|
223
|
+
// 直连非流式:把上游回的正文摘要也记下(这就是「组员回了什么」)
|
|
224
|
+
fwdEvt("relay-forward", { via: "direct", ok: r.status < 400, status: r.status, ms: Date.now() - t0, respChars: txt.length, respBody: snippet(txt) });
|
|
225
|
+
res.statusCode = r.status;
|
|
193
226
|
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
194
227
|
res.end(txt);
|
|
195
228
|
}
|
|
196
229
|
return;
|
|
197
230
|
} catch (err) {
|
|
231
|
+
fwdEvt("relay-forward", { via: "direct", ok: false, status: 502, ms: Date.now() - t0, error: errMsg(err) });
|
|
198
232
|
return json(res, 502, { error: errMsg(err) });
|
|
199
233
|
}
|
|
200
234
|
}
|
|
201
235
|
const reqId = body.reqId || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
202
236
|
const fwdBody = body.body || { model: body.model, messages: body.messages, stream: body.stream };
|
|
203
|
-
const evtData = { ts: Date.now(), type: "relay-forward", target, via: "leader", group: groupName, hops, reqId, model: fwdBody.model };
|
|
204
|
-
if (bus) bus.emit(evtData);
|
|
205
|
-
logs?.appendEvent?.(evtData);
|
|
206
237
|
const staleMs = Number(process.env.MSLXDFF_BROADBAND_STALE_MS) > 0 ? Number(process.env.MSLXDFF_BROADBAND_STALE_MS) : 90_000;
|
|
207
238
|
if (typeof targetMember.lastSeen === "number" && Date.now() - targetMember.lastSeen > staleMs) {
|
|
208
239
|
res.setHeader("x-mslxdff-relay-via", "broadband");
|
|
240
|
+
fwdEvt("relay-member-result", { reqId, ok: false, status: 502, error: "broadband member stale (no heartbeat)" });
|
|
209
241
|
return json(res, 502, { error: "broadband member stale (no heartbeat)" });
|
|
210
242
|
}
|
|
211
243
|
// 标记走的是宽带中继:下游据此一眼分清「组长直连失败(502 fetch failed)」vs「宽带成员回报的失败」,
|
|
212
244
|
// 两者响应体可能都是 {"error":"fetch failed"},只有这个头能区分(成员侧已把真实原因写进 errors.log)。
|
|
213
245
|
res.setHeader("x-mslxdff-relay-via", "broadband");
|
|
246
|
+
const tFwd = Date.now();
|
|
247
|
+
// 组长「已交给成员」:入队即记 —— 成员有没有真正收到,由成员侧那条 relay-task-recv 佐证
|
|
248
|
+
fwdEvt("relay-queued", { reqId, via: "broadband" });
|
|
214
249
|
try {
|
|
215
250
|
const resultPromise = enqueueRelay({ group: groupName, target: targetMember.url, reqId, body: fwdBody, hops });
|
|
216
251
|
const result = await resultPromise;
|
|
217
252
|
if (result && typeof result.status === "number") {
|
|
253
|
+
const rb = result.body == null ? "" : (typeof result.body === "string" ? result.body : JSON.stringify(result.body));
|
|
254
|
+
// 组长「收到成员回什么」:状态码 + 正文摘要 + 耗时 —— 中继全流程的收尾日志
|
|
255
|
+
fwdEvt("relay-member-result", { reqId, ok: result.status < 400, status: result.status, ms: Date.now() - tFwd, respChars: rb.length, respBody: snippet(rb) });
|
|
218
256
|
res.statusCode = result.status;
|
|
219
257
|
if (result.status >= 500) res.setHeader("x-mslxdff-relay-member-status", String(result.status));
|
|
220
258
|
if (result.headers) for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v);
|
|
@@ -224,8 +262,10 @@ export async function forwardHandler({ req, res, groups, bus, logs, token }) {
|
|
|
224
262
|
} else res.end();
|
|
225
263
|
return;
|
|
226
264
|
}
|
|
265
|
+
fwdEvt("relay-member-result", { reqId, ok: true, status: 200, ms: Date.now() - tFwd, respBody: snippet(result) });
|
|
227
266
|
return json(res, 200, result);
|
|
228
267
|
} catch (err) {
|
|
268
|
+
fwdEvt("relay-member-result", { reqId, ok: false, status: 504, ms: Date.now() - tFwd, error: errMsg(err) || "relay timeout" });
|
|
229
269
|
return json(res, 504, { error: errMsg(err) || "relay timeout" });
|
|
230
270
|
}
|
|
231
271
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 组员串行选路(用户 2026-10-19 决定:不竞速)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么改:并发赛跑会同时占用多个组员的额度/连接,慢节点还会拖住整批;而用户要的语义是
|
|
4
|
+
// 「本机额度用完 → 用延迟最小的组员」,延迟小的先上、不行再下一个。串行让这条优先级真正生效。
|
|
5
|
+
//
|
|
6
|
+
// 本模块只做「按序试」这件事,其余(画目标、转发、记账、事件名)从 peers.js 注入,避免循环依赖。
|
|
7
|
+
export const PEER_SERIAL = true;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 串行试候选:一次只打一个,成功即返回;失败顺位试下一个。
|
|
11
|
+
*
|
|
12
|
+
* @param {object} p
|
|
13
|
+
* @param {Array} p.candidates 已按「延迟最小优先」排好的组员
|
|
14
|
+
* @param {object} p.ctx 请求上下文(evt/peers/body/hops/plugins/peerErrors/triedUrls…)
|
|
15
|
+
* @param {Set} p.tried 已试过的 url 集合
|
|
16
|
+
* @param {object} p.deps { resolvePeerTarget, forwardToPeer, peerName, recordLater,
|
|
17
|
+
* summarizeRequest, runHook, errMsg, perAttemptCap, deadline, budgetMs }
|
|
18
|
+
* @returns {Promise<null | {peer, target, res, latencyMs}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function serialTryPeers({ candidates, ctx, tried, deps }) {
|
|
21
|
+
const {
|
|
22
|
+
resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
|
|
23
|
+
perAttemptCap, deadline, budgetMs,
|
|
24
|
+
} = deps;
|
|
25
|
+
const failed = [];
|
|
26
|
+
// 延迟上限过滤:历史延迟超过「单次尝试上限」的组员直接跳过(它大概率又超时)。
|
|
27
|
+
// 只在**还有别的候选**时才跳过,避免把唯一可用的节点也跳没了。
|
|
28
|
+
const ranked = candidates.filter((p) => {
|
|
29
|
+
let lat = null;
|
|
30
|
+
try {
|
|
31
|
+
// 与 ordered() 同源:先看真实请求 EMA,再回落到心跳上报的 rtt
|
|
32
|
+
lat = ctx.peers?.stat?.(p.url)?.latencyMs ?? p.heartbeatLatencyMs ?? null;
|
|
33
|
+
if (lat != null) lat = Number(lat);
|
|
34
|
+
} catch { lat = null; }
|
|
35
|
+
if (lat == null || !Number.isFinite(lat) || lat <= perAttemptCap) return true;
|
|
36
|
+
if (candidates.length <= 1) return true; // 只剩它 → 仍然试
|
|
37
|
+
ctx.evt("peer-skip-slow", { peer: p.url, name: peerName(p), latencyMs: lat, capMs: perAttemptCap });
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
42
|
+
if (deadline > 0 && Date.now() >= deadline) {
|
|
43
|
+
ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽`, budgetMs, remaining: ranked.length - i });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
const prepared = await resolvePeerTarget(ctx, ranked[i]);
|
|
47
|
+
if (!prepared) continue;
|
|
48
|
+
const { peer, target } = prepared;
|
|
49
|
+
tried.add(peer.url);
|
|
50
|
+
const ctrl = new AbortController();
|
|
51
|
+
const name = peerName(peer);
|
|
52
|
+
ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body), serial: i + 1 });
|
|
53
|
+
if (ctx.plugins?.length) {
|
|
54
|
+
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
const remainingMs = deadline > 0 ? deadline - Date.now() : 0;
|
|
57
|
+
// 单个组员的等待上限 = min(阶段剩余预算, 单次尝试上限)。单次上限保证黑洞节点
|
|
58
|
+
// 不会吃掉整个阶段(否则延迟最小的好组员轮不到)。
|
|
59
|
+
const waitMs = deadline > 0 ? Math.max(1, Math.min(perAttemptCap, remainingMs)) : perAttemptCap;
|
|
60
|
+
const t0 = performance.now();
|
|
61
|
+
const res = await forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl, waitMs);
|
|
62
|
+
const latencyMs = Math.round(performance.now() - t0);
|
|
63
|
+
const isErr = res instanceof Error;
|
|
64
|
+
const status = isErr ? 502 : res.status;
|
|
65
|
+
if (ctx.plugins?.length) {
|
|
66
|
+
runHook(ctx.plugins, "peer:result", { reqId: ctx.reqId, peer: peer.url, model: target, ok: !(isErr || status >= 400), status, latencyMs }).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
if (isErr || status >= 400) {
|
|
69
|
+
const msg = isErr ? errMsg(res).slice(0, 200) : null;
|
|
70
|
+
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push({ peer: peer.url, status, message: msg });
|
|
71
|
+
ctx.evt("peer-attempt", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: false, status, message: msg });
|
|
72
|
+
ctx.evt("peer-error", { peer: peer.url, name, model: target, status, message: msg });
|
|
73
|
+
failed.push({ ok: false, peer, target, res, status });
|
|
74
|
+
recordLater(() => ctx.peers.recordError(peer.url, { status, message: msg }));
|
|
75
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
76
|
+
continue; // 串行:试下一个
|
|
77
|
+
}
|
|
78
|
+
ctx.evt("peer-forward", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: true, status });
|
|
79
|
+
return { peer, target, res, latencyMs };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 全败:补读失败响应体(诊断 + 调用者详情)
|
|
83
|
+
await Promise.all(failed.map(async (o) => {
|
|
84
|
+
if (!o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
85
|
+
try {
|
|
86
|
+
const snip = String(await Promise.race([
|
|
87
|
+
o.res.clone().text(),
|
|
88
|
+
new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
|
|
89
|
+
])).replace(/\s+/g, " ").slice(0, 300);
|
|
90
|
+
if (!snip) return;
|
|
91
|
+
if (Array.isArray(ctx.peerErrors)) {
|
|
92
|
+
const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
|
|
93
|
+
if (rec) rec.message = snip;
|
|
94
|
+
}
|
|
95
|
+
ctx.evt("peer-error", { peer: o.peer.url, name: peerName(o.peer), model: o.target, status: o.status, message: `body=${snip}` });
|
|
96
|
+
} catch {}
|
|
97
|
+
}));
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/src/routes/peers.js
CHANGED
|
@@ -4,6 +4,7 @@ import { errMsg } from "./helpers.js";
|
|
|
4
4
|
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
|
+
import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
|
|
7
8
|
|
|
8
9
|
// peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
|
|
9
10
|
// 截止时间,而不是每批各吃满一次等头超时。实测最坏路径是三批串行各 30s(≈90s),
|
|
@@ -34,6 +35,15 @@ function peerConnectTimeoutMs() {
|
|
|
34
35
|
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// 单组员等待上限(串行模式下逐个体生效,MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS,缺省 20s)。
|
|
39
|
+
// 为什么必须限:串行是「一个失败才试下一个」,若一个黑洞节点吃满 120s(bodyTimeout),
|
|
40
|
+
// 后面延迟更小的好组员根本轮不到——实测 172.93.221.187 常 38~44s 才回/被 abort。
|
|
41
|
+
// 20s 足以覆盖正常首包,又不会让一个坏节点吃掉整个阶段预算;可用 env 调。
|
|
42
|
+
function peerAttemptTimeoutMs() {
|
|
43
|
+
const n = Number(process.env.MSLXDFF_PEER_ATTEMPT_TIMEOUT_MS);
|
|
44
|
+
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
// peer 阶段总预算(MSLXDFF_PEER_RACE_BUDGET_MS,0=不限):一次 peer 阶段(同批或若干批候选)
|
|
38
48
|
// 共享同一个截止时刻。调用方用 peerStageCtx() 起一个阶段,别把 deadline 挂在整个请求上下文上——
|
|
39
49
|
// 否则前一个候选把预算吃光,后一个候选一进 peer 阶段就直接放弃。
|
|
@@ -249,6 +259,10 @@ function peerName(peer) {
|
|
|
249
259
|
return String(peer?.name || "").trim() || null;
|
|
250
260
|
}
|
|
251
261
|
|
|
262
|
+
// 组员试法:**串行**(用户 2026-10-19 决定:不竞速)。实现在 ./peer-serial.js,
|
|
263
|
+
// 本文件只保留调度与并发版(PEER_SERIAL=false 时的旧路径)。
|
|
264
|
+
export { PEER_SERIAL };
|
|
265
|
+
|
|
252
266
|
export async function racePeerCandidates(candidates, ctx) {
|
|
253
267
|
const tried = (ctx.triedUrls ??= new Set());
|
|
254
268
|
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
@@ -256,6 +270,15 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
256
270
|
// 没传的阶段(单批调用)不带预算——保持旧行为,不外溢到整条请求。
|
|
257
271
|
const budgetMs = ctx.peerRaceBudgetMs ?? 0;
|
|
258
272
|
const deadline = Number(ctx.peerRaceDeadline) || 0;
|
|
273
|
+
|
|
274
|
+
// 串行:按 candidates 的既有次序(调用方已按「延迟最小优先」排好)逐个试。
|
|
275
|
+
if (PEER_SERIAL) {
|
|
276
|
+
return serialTryPeers({
|
|
277
|
+
candidates: fresh, ctx, tried,
|
|
278
|
+
deps: { resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg, perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
259
282
|
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
260
283
|
if (deadline > 0 && Date.now() >= deadline) {
|
|
261
284
|
ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽`, budgetMs, remaining: fresh.length - i });
|
|
@@ -140,7 +140,10 @@ export async function tryBroadbandRelay({ groups, token: myToken, model, body, h
|
|
|
140
140
|
}
|
|
141
141
|
} catch {}
|
|
142
142
|
}
|
|
143
|
-
if (!allCandidates.length)
|
|
143
|
+
if (!allCandidates.length) {
|
|
144
|
+
evt?.("relay-miss", { reqId, model, reason: "no-candidates", candidates: 0 });
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
144
147
|
for (const cand of allCandidates) {
|
|
145
148
|
try {
|
|
146
149
|
evt?.("relay-try", { reqId, model, via: cand.via, target: cand.target, group: cand.group });
|
package/src/runtime/broadband.js
CHANGED
|
@@ -24,6 +24,11 @@ export function createBroadbandExec({ token, upstream, logs, log = console.log,
|
|
|
24
24
|
const t0 = Date.now();
|
|
25
25
|
const model = String(body?.model || "");
|
|
26
26
|
const fl = withOutputFloor(body); // 首发输出额度托底:leader 转来的 body 不经 pipeline,成员侧自托(评审 P1)
|
|
27
|
+
// 成员「收到任务」:谁转来的、是什么规模、转给谁 —— 中继全流程在**成员这一跳**的入口日志。
|
|
28
|
+
// 没有它,组长侧只知道"入队了",无从确认成员是否真收到、收到的是什么(用户实证要求「日志要全面」)。
|
|
29
|
+
const nMsgs = Array.isArray(body?.messages) ? body.messages.length : 0;
|
|
30
|
+
const nTools = Array.isArray(body?.tools) ? body.tools.length : 0;
|
|
31
|
+
log(`broadband task recv: group=${g.name} req=${reqId} from leader=${g.leaderUrl} model=${model} messages=${nMsgs} tools=${nTools}`);
|
|
27
32
|
if (fl !== body) log(`output-floor(broadband): req=${reqId} ${(body?.max_tokens ?? body?.max_completion_tokens)}→${(fl.max_tokens ?? fl.max_completion_tokens)}`);
|
|
28
33
|
try {
|
|
29
34
|
const upRes = await upstream.chat(fl);
|
|
@@ -92,20 +97,30 @@ export function startBroadband({ token, upstream, logs, log = console.log }) {
|
|
|
92
97
|
return true;
|
|
93
98
|
})();
|
|
94
99
|
const execAndPost = createBroadbandExec({ token, upstream, logs, log });
|
|
100
|
+
// 心跳往返延迟:成员侧自己量「上一轮 heartbeat 的 RTT」,随下一次心跳上报给组长。
|
|
101
|
+
// 组长侧据此排序「谁的延迟更低」(选路=延迟最小优先,见 routes/peers.js 的 ordered)。
|
|
102
|
+
// 用上一轮而不是本轮的原因:本轮 RTT 要等响应回来才知道,来不及带在这次请求里。
|
|
103
|
+
const hbRtt = new Map(); // group name -> ms
|
|
95
104
|
const doHeartbeat = async () => {
|
|
96
105
|
for (const g of broadbandGroups()) {
|
|
97
106
|
try {
|
|
107
|
+
const t0 = Date.now();
|
|
98
108
|
const res = await compatFetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
99
109
|
method: "POST",
|
|
100
110
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
101
|
-
body: JSON.stringify({ name: g.name, group: g.name }),
|
|
111
|
+
body: JSON.stringify({ name: g.name, group: g.name, rttMs: hbRtt.get(g.name) ?? null }),
|
|
102
112
|
signal: timeoutSignal(5000),
|
|
103
113
|
});
|
|
114
|
+
const rtt = Date.now() - t0;
|
|
115
|
+
hbRtt.set(g.name, rtt);
|
|
104
116
|
if (!res.ok) {
|
|
105
117
|
const txt = await res.text().catch(() => "");
|
|
106
118
|
log(`broadband heartbeat ${g.name}: ${res.status} ${txt.slice(0, 100)}`);
|
|
119
|
+
} else {
|
|
120
|
+
log(`broadband heartbeat ${g.name}: ok rtt=${rtt}ms`);
|
|
107
121
|
}
|
|
108
122
|
} catch (err) {
|
|
123
|
+
hbRtt.delete(g.name);
|
|
109
124
|
log(`broadband heartbeat ${g.name}: failed — ${errMsg(err)}`);
|
|
110
125
|
}
|
|
111
126
|
}
|
|
@@ -46,10 +46,22 @@ export function createUpstreamEngine(opts = {}) {
|
|
|
46
46
|
const via = useResponses ? "@ai-sdk/openai · responses" : "@ai-sdk/openai-compatible · chat";
|
|
47
47
|
try { console.error(`[upstream-engine] sdk 引擎生效(${via})`); } catch {}
|
|
48
48
|
}
|
|
49
|
-
// 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在);
|
|
50
|
-
// 也走这条委派——重试环在 legacy.chat
|
|
51
|
-
//
|
|
52
|
-
|
|
49
|
+
// 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在);free-429 的**请求内 v6 换出口**
|
|
50
|
+
// 也走这条委派——重试环在 legacy.chat 里。
|
|
51
|
+
// ⚠ 顺序契约(用户 2026-10-19):先借人(组员/组长,用对方 IPv4 出口),全都用完才换出口。
|
|
52
|
+
// 故 v6 换出口**默认关**,只有显式 MSLXDFF_V6_INREQUEST_ROTATE=1 才委派;
|
|
53
|
+
// 默认把 429 原样返回,交给 chat-pipeline 去试组员/组长。
|
|
54
|
+
const v6RotateOn = (() => {
|
|
55
|
+
const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
|
|
56
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
57
|
+
const s = String(raw).trim().toLowerCase();
|
|
58
|
+
return !(s === "0" || s === "false" || s === "off" || s === "no");
|
|
59
|
+
})();
|
|
60
|
+
const canRotate = v6RotateOn && typeof dispatcher?.rotateFor === "function";
|
|
61
|
+
// 原语义:撞 429 且是 free 模型,且(非匿名首试 或 有 v6 池可换址)⇒ 委派 legacy 重试环。
|
|
62
|
+
// 新语义:**只有显式开启 v6 请求内换址时才委派**;默认 429 原样返回,交给上层去借人
|
|
63
|
+
// (组员/组长,用对方 IPv4 出口),符合用户 2026-10-19 的「先借人再换出口」顺序。
|
|
64
|
+
if (res.status === 429 && isFreeModel(body?.model) && (anonFirst ? canRotate : v6RotateOn)) return legacy.chat(body);
|
|
53
65
|
return res;
|
|
54
66
|
} catch (e) {
|
|
55
67
|
if (e && e._sdkLoadFailed) {
|
package/src/upstream.js
CHANGED
|
@@ -188,14 +188,24 @@ export function createUpstreamClient({
|
|
|
188
188
|
try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
191
|
+
// 请求内换出口(v6 池):撞 429 时在同一请求里换源地址重发。
|
|
192
|
+
//
|
|
193
|
+
// ⚠ 顺序契约(用户 2026-10-19 明确):**先借人,再换出口**。
|
|
194
|
+
// 429 ⇒ ① 先走组员/组长接力(用**对方的 IPv4 出口**);② 全都没得用/都失败,
|
|
195
|
+
// 才轮到 WARP / IPv6 换出口拿"新的自然额度"。WARP/IPv6 是另一层兜底,不该抢在借人之前。
|
|
196
|
+
// 故本功能**默认关闭**,需要时才显式开:MSLXDFF_V6_INREQUEST_ROTATE=1(原 ADR-0061 行为)。
|
|
197
|
+
// 关闭时 429 原样交回上层 → chat-pipeline 去试组员/组长,符合用户要的顺序。
|
|
194
198
|
// 边界(探测实证过的硬约束):① 仅头部即 429 才换(不碰半开的 200 流);② 429 错误体已被
|
|
195
199
|
// transport 非流分支读尽,无连接泄漏;③ 每轮换的是 session 粘的那个槽(swap 原地换 addr/e.agent,
|
|
196
200
|
// 粘性映射不失效);④ 次数上限防 429 风暴空烧认领地址;⑤ 无 rotateFor 的 dispatcher(直连/WARP)
|
|
197
201
|
// 条件短路——WARP 换出口=重注册设备 6s~600s,在请求内做只会白等,**设计上排除**。
|
|
198
|
-
|
|
202
|
+
const inRequestRotateOn = (() => {
|
|
203
|
+
const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
|
|
204
|
+
if (raw === undefined || raw === null || raw === "") return false; // 缺省关:先借人
|
|
205
|
+
const s = String(raw).trim().toLowerCase();
|
|
206
|
+
return !(s === "0" || s === "false" || s === "off" || s === "no");
|
|
207
|
+
})();
|
|
208
|
+
if (inRequestRotateOn && freeLane && res.status === 429 && typeof transport.dispatcher?.rotateFor === "function") {
|
|
199
209
|
const maxRot = envIntOrZero("MSLXDFF_V6_INREQUEST_RETRIES", 3); // 0 = 关掉请求内换出口
|
|
200
210
|
let rotated = 0;
|
|
201
211
|
while (res.status === 429 && rotated < maxRot) {
|