mslxdff 0.1.122 → 0.1.123
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/routes/peers.js +77 -21
package/package.json
CHANGED
package/src/routes/peers.js
CHANGED
|
@@ -3,10 +3,53 @@ import { isAutoModel } from "../auto.js";
|
|
|
3
3
|
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
|
-
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
6
|
+
import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
7
7
|
|
|
8
8
|
const PEER_TIMEOUT_MS = 30_000;
|
|
9
9
|
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
10
|
+
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
11
|
+
|
|
12
|
+
function peerConnectTimeoutMs() {
|
|
13
|
+
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
14
|
+
return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
|
|
18
|
+
// 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
|
|
19
|
+
let peerDispatcher = null;
|
|
20
|
+
function getPeerDispatcher() {
|
|
21
|
+
if (peerDispatcher) return peerDispatcher;
|
|
22
|
+
const { Agent } = getUndici();
|
|
23
|
+
if (!Agent) return null;
|
|
24
|
+
try {
|
|
25
|
+
peerDispatcher = new Agent({
|
|
26
|
+
connect: { timeout: peerConnectTimeoutMs() },
|
|
27
|
+
headersTimeout: PEER_TIMEOUT_MS,
|
|
28
|
+
bodyTimeout: PEER_TIMEOUT_MS,
|
|
29
|
+
keepAliveTimeout: 30_000,
|
|
30
|
+
keepAliveMaxTimeout: 60_000,
|
|
31
|
+
});
|
|
32
|
+
} catch { peerDispatcher = null; }
|
|
33
|
+
return peerDispatcher;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
|
|
37
|
+
// 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
|
|
38
|
+
// 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
|
|
39
|
+
export function stripCallerBoundReasoning(body) {
|
|
40
|
+
const messages = body?.messages;
|
|
41
|
+
if (!Array.isArray(messages)) return body;
|
|
42
|
+
let changed = false;
|
|
43
|
+
const out = messages.map((m) => {
|
|
44
|
+
if (!m || m.role !== "assistant") return m;
|
|
45
|
+
if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
|
|
46
|
+
changed = true;
|
|
47
|
+
const copy = { ...m };
|
|
48
|
+
delete copy.reasoning_items;
|
|
49
|
+
return copy;
|
|
50
|
+
});
|
|
51
|
+
return changed ? { ...body, messages: out } : body;
|
|
52
|
+
}
|
|
10
53
|
|
|
11
54
|
function peerHealthTtlMs() {
|
|
12
55
|
const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
|
|
@@ -75,11 +118,13 @@ async function forwardToPeer(peer, body, model, hops) {
|
|
|
75
118
|
// ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
|
|
76
119
|
const shareHeader = buildShareKeysHeader(model);
|
|
77
120
|
if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
|
|
121
|
+
const dispatcher = getPeerDispatcher();
|
|
78
122
|
return await compatFetch(`${peer.url}/v1/chat/completions`, {
|
|
79
123
|
method: "POST",
|
|
80
124
|
headers,
|
|
81
|
-
body: JSON.stringify({ ...body, model }),
|
|
125
|
+
body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
|
|
82
126
|
signal: controller.signal,
|
|
127
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
83
128
|
});
|
|
84
129
|
} catch (err) {
|
|
85
130
|
return err;
|
|
@@ -117,15 +162,30 @@ export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
|
|
|
117
162
|
? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
|
|
118
163
|
: 3;
|
|
119
164
|
|
|
165
|
+
// 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
|
|
166
|
+
let peerRecordChain = Promise.resolve();
|
|
167
|
+
function recordLater(fn) {
|
|
168
|
+
peerRecordChain = peerRecordChain.then(fn).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
120
171
|
export async function racePeerCandidates(candidates, ctx) {
|
|
121
|
-
|
|
122
|
-
|
|
172
|
+
const tried = (ctx.triedUrls ??= new Set());
|
|
173
|
+
const fresh = candidates.filter((p) => !tried.has(p.url));
|
|
174
|
+
for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
|
|
175
|
+
const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
|
|
123
176
|
const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
|
|
124
177
|
if (!prepared.length) continue;
|
|
125
178
|
const completed = await new Promise((resolve) => {
|
|
126
179
|
const order = [];
|
|
127
180
|
const total = prepared.length;
|
|
181
|
+
let settled = false;
|
|
182
|
+
const finish = (winner) => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
resolve({ list: order, winner });
|
|
186
|
+
};
|
|
128
187
|
for (const { peer, target } of prepared) {
|
|
188
|
+
tried.add(peer.url);
|
|
129
189
|
ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1 });
|
|
130
190
|
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
131
191
|
if (ctx.plugins?.length) {
|
|
@@ -147,29 +207,29 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
147
207
|
ctx.logError(ctx.model, status, res instanceof Error ? `peer ${peer.url} ${errMsg(res)}` : `peer ${peer.url} ${status}`);
|
|
148
208
|
ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
|
|
149
209
|
order.push({ ok: false, peer, target, res, status });
|
|
210
|
+
recordLater(() => ctx.peers.recordError(peer.url, { status }));
|
|
211
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
212
|
+
if (order.length === total) finish(null);
|
|
150
213
|
} else {
|
|
151
|
-
|
|
214
|
+
const entry = { ok: true, peer, target, res, latencyMs };
|
|
215
|
+
order.push(entry);
|
|
216
|
+
if (!settled) {
|
|
217
|
+
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
218
|
+
finish(entry);
|
|
219
|
+
} else {
|
|
220
|
+
recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
|
|
221
|
+
}
|
|
152
222
|
}
|
|
153
|
-
if (order.length === total) resolve(order);
|
|
154
223
|
});
|
|
155
224
|
}
|
|
156
225
|
});
|
|
157
|
-
const winner = completed.
|
|
226
|
+
const winner = completed.winner;
|
|
158
227
|
if (winner) {
|
|
159
|
-
for (const o of completed) {
|
|
160
|
-
if (o === winner) continue;
|
|
161
|
-
if (!o.ok) {
|
|
162
|
-
await ctx.peers.recordError(o.peer.url, { status: o.status });
|
|
163
|
-
await ctx.peers.recordResult(o.peer.url, { ok: false });
|
|
164
|
-
} else {
|
|
165
|
-
await ctx.peers.recordResult(o.peer.url, { ok: true, latencyMs: o.latencyMs, model: o.target });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
228
|
return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
|
|
169
229
|
}
|
|
170
230
|
// 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
|
|
171
231
|
// 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
|
|
172
|
-
await Promise.all(completed.map(async (o) => {
|
|
232
|
+
await Promise.all(completed.list.map(async (o) => {
|
|
173
233
|
if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
|
|
174
234
|
try {
|
|
175
235
|
const snip = String(await Promise.race([
|
|
@@ -184,10 +244,6 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
184
244
|
ctx.logError(ctx.model, o.status, `peer ${o.peer.url} ${o.status} body=${snip}`);
|
|
185
245
|
} catch {}
|
|
186
246
|
}));
|
|
187
|
-
for (const o of completed) {
|
|
188
|
-
await ctx.peers.recordError(o.peer.url, { status: o.status });
|
|
189
|
-
await ctx.peers.recordResult(o.peer.url, { ok: false });
|
|
190
|
-
}
|
|
191
247
|
}
|
|
192
248
|
return null;
|
|
193
249
|
}
|