mslxdff 0.1.170 → 0.1.172

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.
Files changed (110) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/docs/cli_help_mini.md +143 -0
  3. package/package.json +1 -1
  4. package/src/anthropic/count-tokens.js +39 -0
  5. package/src/anthropic/translate.js +20 -41
  6. package/src/auto.js +254 -254
  7. package/src/bench/cline-bench.js +42 -42
  8. package/src/bench/probe.js +70 -70
  9. package/src/bench/report.js +162 -162
  10. package/src/bench/runner.js +77 -77
  11. package/src/bench/via-probe.js +124 -124
  12. package/src/bench/via-routes.js +87 -87
  13. package/src/bench/workbuddy-bench.js +54 -54
  14. package/src/chat/engine.js +160 -160
  15. package/src/chat/gateway.js +163 -163
  16. package/src/chat/orchestrator.js +234 -234
  17. package/src/chat/prompt.js +70 -70
  18. package/src/chat/repl.js +88 -88
  19. package/src/chat/terminal.js +135 -135
  20. package/src/chat/tools.js +306 -306
  21. package/src/chat-pipeline/index.js +131 -131
  22. package/src/chat-pipeline/policy.js +76 -76
  23. package/src/cli/commands/group.js +249 -249
  24. package/src/cli/commands/model/picks.js +50 -50
  25. package/src/cli/commands/provider/bench-via.js +247 -247
  26. package/src/cli/commands/provider/bench.js +141 -141
  27. package/src/cli/commands/provider/index.js +133 -133
  28. package/src/cli/commands/provider/models.js +145 -145
  29. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  30. package/src/cli/commands/sync.js +310 -310
  31. package/src/cli/status.js +279 -279
  32. package/src/daemon.js +112 -112
  33. package/src/model-capabilities/context-lookup.js +65 -0
  34. package/src/model-capabilities/enrich.js +86 -86
  35. package/src/model-capabilities/index.js +183 -183
  36. package/src/model-capabilities/merge.js +50 -35
  37. package/src/model-capabilities/parse.js +70 -70
  38. package/src/models.js +225 -225
  39. package/src/providers/AGENTS.md +49 -0
  40. package/src/providers/cline/auth.js +228 -228
  41. package/src/providers/cline/chat.js +307 -307
  42. package/src/providers/cline.js +2 -2
  43. package/src/providers/codearts/concurrency.js +34 -0
  44. package/src/providers/codearts/index.js +11 -3
  45. package/src/providers/globalqwenwork/cosy.js +123 -123
  46. package/src/providers/globalqwenwork/crypto.js +218 -218
  47. package/src/providers/globalqwenwork/http.js +20 -20
  48. package/src/providers/globalqwenwork/payload.js +4 -2
  49. package/src/providers/globalqwenwork/rsa.js +54 -54
  50. package/src/providers/globalqwenwork/sse.js +268 -268
  51. package/src/providers/globalqwenwork/stream.js +130 -130
  52. package/src/providers/globalqwenwork/upstream.js +122 -122
  53. package/src/providers/keyring.js +60 -60
  54. package/src/providers/qoder/chat.js +203 -200
  55. package/src/providers/qoder/index.js +249 -249
  56. package/src/providers/qoder/sse.js +145 -145
  57. package/src/providers/qwenwork/account-store.js +133 -133
  58. package/src/providers/qwenwork/constants.js +67 -67
  59. package/src/providers/qwenwork/cosy.js +120 -120
  60. package/src/providers/qwenwork/crypto.js +218 -218
  61. package/src/providers/qwenwork/http.js +20 -20
  62. package/src/providers/qwenwork/index.js +327 -327
  63. package/src/providers/qwenwork/payload.js +144 -142
  64. package/src/providers/qwenwork/rsa.js +54 -54
  65. package/src/providers/qwenwork/sse.js +268 -268
  66. package/src/providers/qwenwork/stream.js +130 -130
  67. package/src/providers/qwenwork/upstream.js +120 -120
  68. package/src/providers/qwenwork.js +1 -1
  69. package/src/providers/raccoon/chat.js +10 -5
  70. package/src/providers/raccoon/const.js +4 -4
  71. package/src/providers/registry.js +79 -79
  72. package/src/providers/traework/models.js +4 -3
  73. package/src/providers/workbuddy/chat.js +248 -248
  74. package/src/providers/workbuddy/payload.js +16 -7
  75. package/src/providers/workbuddy/reshape.js +152 -152
  76. package/src/providers/workbuddy.js +2 -2
  77. package/src/providers/zcode/chat.js +7 -0
  78. package/src/reasoning.js +32 -32
  79. package/src/responses/translate.js +4 -0
  80. package/src/routes/AGENTS.md +37 -0
  81. package/src/routes/chat/exhausted-handler.js +5 -0
  82. package/src/routes/chat/gateway.js +46 -46
  83. package/src/routes/chat/relay-pipeline.js +294 -291
  84. package/src/routes/chat/via-route-handler.js +146 -146
  85. package/src/routes/hedge.js +255 -255
  86. package/src/routes/messages-route.js +1 -1
  87. package/src/routes/models-route.js +167 -167
  88. package/src/routes/peers.js +273 -273
  89. package/src/routes/stream-scan.js +47 -21
  90. package/src/routes/stream.js +385 -385
  91. package/src/runtime/bootstrap.js +50 -50
  92. package/src/runtime/provider-gate.js +36 -36
  93. package/src/runtime/providers-setup.js +165 -165
  94. package/src/server.js +69 -69
  95. package/src/state/schemas/allowlist.js +92 -92
  96. package/src/sync-opencode.js +280 -280
  97. package/src/talk-full.js +1 -0
  98. package/src/talk-log.js +3 -2
  99. package/src/thinking-effort.js +67 -0
  100. package/src/transport/index.js +244 -244
  101. package/src/transport/pool.js +56 -56
  102. package/src/transport/retry.js +24 -24
  103. package/src/transport/sse.js +93 -93
  104. package/src/upstream-engine/sdk/convert.js +6 -0
  105. package/src/upstream-probe/display.js +52 -52
  106. package/src/upstream-probe/probe.js +49 -49
  107. package/src/upstream-probe/rotate.js +110 -110
  108. package/src/upstream-probe/start.js +45 -45
  109. package/src/upstream-responses.js +5 -0
  110. package/src/upstream.js +289 -289
@@ -1,273 +1,273 @@
1
- import { performance } from "node:perf_hooks";
2
- import { isAutoModel } from "../auto.js";
3
- import { errMsg } from "./helpers.js";
4
- import { runHook } from "../plugins.js";
5
- import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
6
- import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
7
-
8
- const PEER_TIMEOUT_MS = 30_000;
9
- const PEER_STATUS_TIMEOUT_MS = 2_000;
10
- const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
11
- // SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
12
- // 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
13
- // 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
14
- // undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
15
- import { summarizeRequest } from "../model-trace.js";
16
- const PEER_BODY_TIMEOUT_MS = 120_000;
17
- export { PEER_BODY_TIMEOUT_MS };
18
-
19
- function peerConnectTimeoutMs() {
20
- const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
21
- return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
22
- }
23
-
24
- // 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
25
- // 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
26
- let peerDispatcher = null;
27
- function getPeerDispatcher() {
28
- if (peerDispatcher) return peerDispatcher;
29
- const { Agent } = getUndici();
30
- if (!Agent) return null;
31
- try {
32
- peerDispatcher = new Agent({
33
- connect: { timeout: peerConnectTimeoutMs() },
34
- headersTimeout: PEER_TIMEOUT_MS,
35
- bodyTimeout: PEER_BODY_TIMEOUT_MS,
36
- keepAliveTimeout: 30_000,
37
- keepAliveMaxTimeout: 60_000,
38
- });
39
- } catch { peerDispatcher = null; }
40
- return peerDispatcher;
41
- }
42
-
43
- // 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
44
- // 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
45
- // 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
46
- export function stripCallerBoundReasoning(body) {
47
- const messages = body?.messages;
48
- if (!Array.isArray(messages)) return body;
49
- let changed = false;
50
- const out = messages.map((m) => {
51
- if (!m || m.role !== "assistant") return m;
52
- if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
53
- changed = true;
54
- const copy = { ...m };
55
- delete copy.reasoning_items;
56
- return copy;
57
- });
58
- return changed ? { ...body, messages: out } : body;
59
- }
60
-
61
- function peerHealthTtlMs() {
62
- const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
63
- return Number.isInteger(n) && n >= 0 ? n : 30_000;
64
- }
65
-
66
- const healthCache = new Map(); // url -> { at, data }
67
- const healthInflight = new Map(); // url -> Promise
68
-
69
- export function clearPeerHealthCache() {
70
- healthCache.clear();
71
- healthInflight.clear();
72
- }
73
-
74
- export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
75
- const key = peer?.url || "";
76
- const ttl = peerHealthTtlMs();
77
- if (ttl > 0) {
78
- const hit = healthCache.get(key);
79
- if (hit && Date.now() - hit.at < ttl) return hit.data;
80
- const inflight = healthInflight.get(key);
81
- if (inflight) return inflight;
82
- }
83
- const p = (async () => {
84
- try {
85
- const res = await fetchImpl(`${peer.url}/v1/models/status`, {
86
- headers: {
87
- "Authorization": `Bearer ${peer.token || ""}`,
88
- "Accept": "application/json",
89
- },
90
- signal: timeoutSignal(timeoutMs),
91
- });
92
- if (!res.ok) return [];
93
- const j = await res.json().catch(() => ({}));
94
- return (j.data || [])
95
- .filter((m) => m && typeof m.id === "string" && m.status === "normal")
96
- .map((m) => m.id);
97
- } catch {
98
- return [];
99
- }
100
- })();
101
- if (ttl > 0) {
102
- healthInflight.set(key, p);
103
- try {
104
- const data = await p;
105
- healthCache.set(key, { at: Date.now(), data });
106
- return data;
107
- } finally {
108
- healthInflight.delete(key);
109
- }
110
- }
111
- return p;
112
- }
113
-
114
- // controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
115
- // 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。30s timer 仅覆盖"等响应头"。
116
- async function forwardToPeer(peer, body, model, hops, controller = new AbortController()) {
117
- const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
118
- try {
119
- const headers = {
120
- "Content-Type": "application/json",
121
- "Authorization": `Bearer ${peer.token}`,
122
- "x-mslxdff-hops": String(hops + 1),
123
- "x-mslxdff-model-lock": model,
124
- "Accept": "text/event-stream",
125
- };
126
- // ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
127
- const shareHeader = buildShareKeysHeader(model);
128
- if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
129
- const dispatcher = getPeerDispatcher();
130
- return await compatFetch(`${peer.url}/v1/chat/completions`, {
131
- method: "POST",
132
- headers,
133
- body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
134
- signal: controller.signal,
135
- ...(dispatcher ? { dispatcher } : {}),
136
- });
137
- } catch (err) {
138
- return err;
139
- } finally {
140
- clearTimeout(timer);
141
- }
142
- }
143
-
144
- async function resolvePeerTarget(ctx, peer) {
145
- const prevModel = ctx.peers.stat(peer.url)?.model;
146
- const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
147
- if (hot) return { peer, target: prevModel };
148
- const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
149
- if (isExplicit) {
150
- const healthy = await peerHealthyModels(peer);
151
- if (!healthy.length) {
152
- ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
153
- return { peer, target: ctx.model };
154
- }
155
- ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
156
- return { peer, target: ctx.model };
157
- }
158
- const healthy = await peerHealthyModels(peer);
159
- if (!healthy.length) {
160
- await ctx.peers.recordError(peer.url);
161
- ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
162
- ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
163
- return null;
164
- }
165
- ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
166
- return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
167
- }
168
-
169
- export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
170
- ? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
171
- : 3;
172
-
173
- // 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
174
- let peerRecordChain = Promise.resolve();
175
- function recordLater(fn) {
176
- peerRecordChain = peerRecordChain.then(fn).catch(() => {});
177
- }
178
-
179
- export async function racePeerCandidates(candidates, ctx) {
180
- const tried = (ctx.triedUrls ??= new Set());
181
- const fresh = candidates.filter((p) => !tried.has(p.url));
182
- for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
183
- const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
184
- const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
185
- if (!prepared.length) continue;
186
- const completed = await new Promise((resolve) => {
187
- const order = [];
188
- const total = prepared.length;
189
- const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
190
- let settled = false;
191
- const finish = (winner) => {
192
- if (settled) return;
193
- settled = true;
194
- if (winner) {
195
- for (const c of ctrls.values()) {
196
- if (c === winner.ctrl) continue;
197
- c.killedByUs = true;
198
- c.abort();
199
- }
200
- }
201
- resolve({ list: order, winner });
202
- };
203
- for (const { peer, target } of prepared) {
204
- tried.add(peer.url);
205
- const ctrl = new AbortController();
206
- ctrls.set(peer.url, ctrl);
207
- ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
208
- // 插件 hook:peer:beforeForward — 转发给组员前观察
209
- if (ctx.plugins?.length) {
210
- runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
211
- }
212
- const t0 = performance.now();
213
- forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl).then((res) => {
214
- const latencyMs = Math.round(performance.now() - t0);
215
- const failed = res instanceof Error || res.status >= 400;
216
- // winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
217
- if (res instanceof Error && ctrl.killedByUs) {
218
- order.push({ ok: false, peer, target, res, status: 0, killed: true });
219
- if (order.length === total) finish(null);
220
- return;
221
- }
222
- ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
223
- // 插件 hook:peer:result — 组员响应后观察
224
- if (ctx.plugins?.length) {
225
- runHook(ctx.plugins, "peer:result", { reqId: ctx.reqId, peer: peer.url, model: target, ok: !failed, status: failed ? (res instanceof Error ? 502 : res.status) : res.status, latencyMs }).catch(() => {});
226
- }
227
- if (failed) {
228
- const status = res instanceof Error ? 502 : res.status;
229
- const failRec = { peer: peer.url, status, message: res instanceof Error ? errMsg(res) : null };
230
- if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
231
- ctx.logError(ctx.model, status, res instanceof Error ? `peer ${peer.url} ${errMsg(res)}` : `peer ${peer.url} ${status}`);
232
- ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
233
- order.push({ ok: false, peer, target, res, status });
234
- recordLater(() => ctx.peers.recordError(peer.url, { status }));
235
- recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
236
- if (order.length === total) finish(null);
237
- } else {
238
- const entry = { ok: true, peer, target, res, latencyMs, ctrl };
239
- order.push(entry);
240
- if (!settled) {
241
- // 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
242
- finish(entry);
243
- } else {
244
- recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
245
- }
246
- }
247
- });
248
- }
249
- });
250
- const winner = completed.winner;
251
- if (winner) {
252
- return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
253
- }
254
- // 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
255
- // 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
256
- await Promise.all(completed.list.map(async (o) => {
257
- if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
258
- try {
259
- const snip = String(await Promise.race([
260
- o.res.clone().text(),
261
- new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
262
- ])).replace(/\s+/g, " ").slice(0, 300);
263
- if (!snip) return;
264
- if (Array.isArray(ctx.peerErrors)) {
265
- const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
266
- if (rec) rec.message = snip;
267
- }
268
- ctx.logError(ctx.model, o.status, `peer ${o.peer.url} ${o.status} body=${snip}`);
269
- } catch {}
270
- }));
271
- }
272
- return null;
273
- }
1
+ import { performance } from "node:perf_hooks";
2
+ import { isAutoModel } from "../auto.js";
3
+ import { errMsg } from "./helpers.js";
4
+ import { runHook } from "../plugins.js";
5
+ import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
6
+ import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
7
+
8
+ const PEER_TIMEOUT_MS = 30_000;
9
+ const PEER_STATUS_TIMEOUT_MS = 2_000;
10
+ const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
11
+ // SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
12
+ // 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
13
+ // 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
14
+ // undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
15
+ import { summarizeRequest } from "../model-trace.js";
16
+ const PEER_BODY_TIMEOUT_MS = 120_000;
17
+ export { PEER_BODY_TIMEOUT_MS };
18
+
19
+ function peerConnectTimeoutMs() {
20
+ const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
21
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_PEER_CONNECT_TIMEOUT_MS;
22
+ }
23
+
24
+ // 连接级超时(DNS + TCP/TLS 握手):黑洞节点(端口挂起)必须快速失败,
25
+ // 不能占用 30s 的响应超时(曾把一次请求拖到 92s)。仅 undici 可用时生效。
26
+ let peerDispatcher = null;
27
+ function getPeerDispatcher() {
28
+ if (peerDispatcher) return peerDispatcher;
29
+ const { Agent } = getUndici();
30
+ if (!Agent) return null;
31
+ try {
32
+ peerDispatcher = new Agent({
33
+ connect: { timeout: peerConnectTimeoutMs() },
34
+ headersTimeout: PEER_TIMEOUT_MS,
35
+ bodyTimeout: PEER_BODY_TIMEOUT_MS,
36
+ keepAliveTimeout: 30_000,
37
+ keepAliveMaxTimeout: 60_000,
38
+ });
39
+ } catch { peerDispatcher = null; }
40
+ return peerDispatcher;
41
+ }
42
+
43
+ // 跨 caller 中继必须剥离 reasoning 加密态:encrypted_content 由上游按 caller(出口)签发,
44
+ // 换个节点转发会被拒(400 "reasoning encrypted_content was not issued to this caller")。
45
+ // 明文 reasoning_content 保留(不绑定 caller,thinking 模式需要)。
46
+ export function stripCallerBoundReasoning(body) {
47
+ const messages = body?.messages;
48
+ if (!Array.isArray(messages)) return body;
49
+ let changed = false;
50
+ const out = messages.map((m) => {
51
+ if (!m || m.role !== "assistant") return m;
52
+ if (!Array.isArray(m.reasoning_items) || !m.reasoning_items.length) return m;
53
+ changed = true;
54
+ const copy = { ...m };
55
+ delete copy.reasoning_items;
56
+ return copy;
57
+ });
58
+ return changed ? { ...body, messages: out } : body;
59
+ }
60
+
61
+ function peerHealthTtlMs() {
62
+ const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
63
+ return Number.isInteger(n) && n >= 0 ? n : 30_000;
64
+ }
65
+
66
+ const healthCache = new Map(); // url -> { at, data }
67
+ const healthInflight = new Map(); // url -> Promise
68
+
69
+ export function clearPeerHealthCache() {
70
+ healthCache.clear();
71
+ healthInflight.clear();
72
+ }
73
+
74
+ export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
75
+ const key = peer?.url || "";
76
+ const ttl = peerHealthTtlMs();
77
+ if (ttl > 0) {
78
+ const hit = healthCache.get(key);
79
+ if (hit && Date.now() - hit.at < ttl) return hit.data;
80
+ const inflight = healthInflight.get(key);
81
+ if (inflight) return inflight;
82
+ }
83
+ const p = (async () => {
84
+ try {
85
+ const res = await fetchImpl(`${peer.url}/v1/models/status`, {
86
+ headers: {
87
+ "Authorization": `Bearer ${peer.token || ""}`,
88
+ "Accept": "application/json",
89
+ },
90
+ signal: timeoutSignal(timeoutMs),
91
+ });
92
+ if (!res.ok) return [];
93
+ const j = await res.json().catch(() => ({}));
94
+ return (j.data || [])
95
+ .filter((m) => m && typeof m.id === "string" && m.status === "normal")
96
+ .map((m) => m.id);
97
+ } catch {
98
+ return [];
99
+ }
100
+ })();
101
+ if (ttl > 0) {
102
+ healthInflight.set(key, p);
103
+ try {
104
+ const data = await p;
105
+ healthCache.set(key, { at: Date.now(), data });
106
+ return data;
107
+ } finally {
108
+ healthInflight.delete(key);
109
+ }
110
+ }
111
+ return p;
112
+ }
113
+
114
+ // controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
115
+ // 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。30s timer 仅覆盖"等响应头"。
116
+ async function forwardToPeer(peer, body, model, hops, controller = new AbortController()) {
117
+ const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
118
+ try {
119
+ const headers = {
120
+ "Content-Type": "application/json",
121
+ "Authorization": `Bearer ${peer.token}`,
122
+ "x-mslxdff-hops": String(hops + 1),
123
+ "x-mslxdff-model-lock": model,
124
+ "Accept": "text/event-stream",
125
+ };
126
+ // ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
127
+ const shareHeader = buildShareKeysHeader(model);
128
+ if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
129
+ const dispatcher = getPeerDispatcher();
130
+ return await compatFetch(`${peer.url}/v1/chat/completions`, {
131
+ method: "POST",
132
+ headers,
133
+ body: JSON.stringify({ ...stripCallerBoundReasoning(body), model }),
134
+ signal: controller.signal,
135
+ ...(dispatcher ? { dispatcher } : {}),
136
+ });
137
+ } catch (err) {
138
+ return err;
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+
144
+ async function resolvePeerTarget(ctx, peer) {
145
+ const prevModel = ctx.peers.stat(peer.url)?.model;
146
+ const hot = ctx.peers.isHot(peer.url) && prevModel === ctx.model;
147
+ if (hot) return { peer, target: prevModel };
148
+ const isExplicit = !!ctx.model && !isAutoModel(ctx.model);
149
+ if (isExplicit) {
150
+ const healthy = await peerHealthyModels(peer);
151
+ if (!healthy.length) {
152
+ ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0, strict: true });
153
+ return { peer, target: ctx.model };
154
+ }
155
+ ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length, strict: true });
156
+ return { peer, target: ctx.model };
157
+ }
158
+ const healthy = await peerHealthyModels(peer);
159
+ if (!healthy.length) {
160
+ await ctx.peers.recordError(peer.url);
161
+ ctx.logError(ctx.model, 0, `peer ${peer.url} has no healthy models`);
162
+ ctx.evt("peer-health", { peer: peer.url, healthy: [], count: 0 });
163
+ return null;
164
+ }
165
+ ctx.evt("peer-health", { peer: peer.url, healthy, count: healthy.length });
166
+ return { peer, target: healthy.includes(ctx.model) ? ctx.model : healthy[0] };
167
+ }
168
+
169
+ export const PEER_RACE_LIMIT = Number(process.env.MSLXDFF_PEER_RACE_LIMIT) > 0
170
+ ? Number(process.env.MSLXDFF_PEER_RACE_LIMIT)
171
+ : 3;
172
+
173
+ // 串行记账队列:失败/迟到成功的记录不阻塞赢家返回,同时避免并发写盘互相覆盖。
174
+ let peerRecordChain = Promise.resolve();
175
+ function recordLater(fn) {
176
+ peerRecordChain = peerRecordChain.then(fn).catch(() => {});
177
+ }
178
+
179
+ export async function racePeerCandidates(candidates, ctx) {
180
+ const tried = (ctx.triedUrls ??= new Set());
181
+ const fresh = candidates.filter((p) => !tried.has(p.url));
182
+ for (let i = 0; i < fresh.length; i += PEER_RACE_LIMIT) {
183
+ const batch = fresh.slice(i, i + PEER_RACE_LIMIT);
184
+ const prepared = (await Promise.all(batch.map((peer) => resolvePeerTarget(ctx, peer)))).filter(Boolean);
185
+ if (!prepared.length) continue;
186
+ const completed = await new Promise((resolve) => {
187
+ const order = [];
188
+ const total = prepared.length;
189
+ const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
190
+ let settled = false;
191
+ const finish = (winner) => {
192
+ if (settled) return;
193
+ settled = true;
194
+ if (winner) {
195
+ for (const c of ctrls.values()) {
196
+ if (c === winner.ctrl) continue;
197
+ c.killedByUs = true;
198
+ c.abort();
199
+ }
200
+ }
201
+ resolve({ list: order, winner });
202
+ };
203
+ for (const { peer, target } of prepared) {
204
+ tried.add(peer.url);
205
+ const ctrl = new AbortController();
206
+ ctrls.set(peer.url, ctrl);
207
+ ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body) });
208
+ // 插件 hook:peer:beforeForward — 转发给组员前观察
209
+ if (ctx.plugins?.length) {
210
+ runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
211
+ }
212
+ const t0 = performance.now();
213
+ forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl).then((res) => {
214
+ const latencyMs = Math.round(performance.now() - t0);
215
+ const failed = res instanceof Error || res.status >= 400;
216
+ // winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
217
+ if (res instanceof Error && ctrl.killedByUs) {
218
+ order.push({ ok: false, peer, target, res, status: 0, killed: true });
219
+ if (order.length === total) finish(null);
220
+ return;
221
+ }
222
+ ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
223
+ // 插件 hook:peer:result — 组员响应后观察
224
+ if (ctx.plugins?.length) {
225
+ runHook(ctx.plugins, "peer:result", { reqId: ctx.reqId, peer: peer.url, model: target, ok: !failed, status: failed ? (res instanceof Error ? 502 : res.status) : res.status, latencyMs }).catch(() => {});
226
+ }
227
+ if (failed) {
228
+ const status = res instanceof Error ? 502 : res.status;
229
+ const failRec = { peer: peer.url, status, message: res instanceof Error ? errMsg(res) : null };
230
+ if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push(failRec);
231
+ ctx.logError(ctx.model, status, res instanceof Error ? `peer ${peer.url} ${errMsg(res)}` : `peer ${peer.url} ${status}`);
232
+ ctx.evt("peer-error", { peer: peer.url, model: target, status, message: res instanceof Error ? errMsg(res) : null });
233
+ order.push({ ok: false, peer, target, res, status });
234
+ recordLater(() => ctx.peers.recordError(peer.url, { status }));
235
+ recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
236
+ if (order.length === total) finish(null);
237
+ } else {
238
+ const entry = { ok: true, peer, target, res, latencyMs, ctrl };
239
+ order.push(entry);
240
+ if (!settled) {
241
+ // 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
242
+ finish(entry);
243
+ } else {
244
+ recordLater(() => ctx.peers.recordResult(peer.url, { ok: true, latencyMs, model: target }));
245
+ }
246
+ }
247
+ });
248
+ }
249
+ });
250
+ const winner = completed.winner;
251
+ if (winner) {
252
+ return { peer: winner.peer, target: winner.target, res: winner.res, latencyMs: winner.latencyMs };
253
+ }
254
+ // 全失败:补读失败响应体(诊断 + 调用者详情)。仅在"本轮无 winner"时执行,
255
+ // 不拖慢成功路径;并行读、每 peer 上限 600ms,body 里才有 400/429 的真实原因。
256
+ await Promise.all(completed.list.map(async (o) => {
257
+ if (o.ok || !o.res || typeof o.res !== "object" || o.res instanceof Error) return;
258
+ try {
259
+ const snip = String(await Promise.race([
260
+ o.res.clone().text(),
261
+ new Promise((r) => { const t = setTimeout(() => r(""), 600); t.unref?.(); }),
262
+ ])).replace(/\s+/g, " ").slice(0, 300);
263
+ if (!snip) return;
264
+ if (Array.isArray(ctx.peerErrors)) {
265
+ const rec = ctx.peerErrors.find((x) => x.peer === o.peer.url && x.status === o.status && !x.message);
266
+ if (rec) rec.message = snip;
267
+ }
268
+ ctx.logError(ctx.model, o.status, `peer ${o.peer.url} ${o.status} body=${snip}`);
269
+ } catch {}
270
+ }));
271
+ }
272
+ return null;
273
+ }
@@ -8,7 +8,13 @@ import { agentLoopEnabled, talkCapChars } from "../talk-full.js"; // 上限单
8
8
 
9
9
  // 响应正文捕获桶(talk log + agent 回路共用一只):只有 relay 建了 detail.talk 才累积,未开启时零开销。
10
10
  // 口径与 chars/reasoningChars 同源(读 delta,不读聚合帧),区别是这里存原文而不是只计数。
11
- // 同样的边界:SSE 帧被 chunk 切断时该帧内容丢失(JSON.parse 失败即跳过)—— 研究用途可接受。
11
+ // 分帧口径(openspec fix-talk-capture-frame-loss):**按行不按 chunk** —— 未以 `\n` 收尾的尾行留在
12
+ // talk.remain 里等下个工作拼回,凑齐的完整 `data:` 行才 parse;流收场由消费方调 captureTalkFlush
13
+ // 回收缺尾换行的末行。历史口径「帧被 chunk 切断即弃(研究用途可接受)」已废——它让 talk/talkfull
14
+ // 的 tool arguments 随机少 1~10 字符(现网取证 43% 路径落空),误导模型侧归因。
15
+ // 不可逆丢弃(切点落在多字节字符内部的拼回行、超限半行)不静默:桶上 dropped 计数显式可见,
16
+ // 语料 meta.droppedFrames / talk 头行 dropped= 透出——「看起来完整其实吃字」比缺失更危险(review P1-1)。
17
+ const TALK_REMAIN_MAX = 2_000_000; // 半行缓冲上限(UTF-16 字符数,≈4MB):远大于任何流式 delta 单行;超限弃半行并显式记 dropped(review P2-5)
12
18
  // 上限真相是 talkCapChars()(MSLXDFF_TALK_CAP_CHARS > full 开 200 万 > full 关 40 万);
13
19
  // 下面的常数只是 createTalkBucket 未传 cap 时的历史缺省,必须与「full 关」档逐字相同(有测试锁死)。
14
20
  const TALK_CAP_DEFAULT = 400_000;
@@ -34,11 +40,12 @@ function talkMergeToolCalls(talk, tcs) {
34
40
  }
35
41
  }
36
42
 
37
- /** 新建累积桶(n/capped 防超长生成把内存吃穿)。cap = 字符上限,未传/非法 → 400_000(现状逐字不变)。 */
43
+ /** 新建累积桶(n/capped 防超长生成把内存吃穿)。cap = 字符上限,未传/非法 → 400_000(现状逐字不变)。
44
+ * remain = 跨 chunk 半行缓冲(openspec fix-talk-capture-frame-loss 行缓冲分帧);dropped = 不可逆丢弃帧计数(P1-1 显式读数)。 */
38
45
  export function createTalkBucket(cap) {
39
46
  const c = Math.floor(Number(cap));
40
47
  const use = Number.isFinite(c) && c > 0 ? c : TALK_CAP_DEFAULT;
41
- return { reasoning: [], content: [], tools: [], n: 0, capped: false, cap: use };
48
+ return { reasoning: [], content: [], tools: [], n: 0, capped: false, cap: use, remain: "", dropped: 0 };
42
49
  }
43
50
 
44
51
  /**
@@ -50,25 +57,44 @@ export function createTalkBucketIfEnabled() {
50
57
  return talkLogEnabled() || agentLoopEnabled() ? createTalkBucket(talkCapChars()) : null;
51
58
  }
52
59
 
53
- /** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。 */
60
+ /** 完整单行消费(talk 桶的唯一落点,从原 captureTalkSse 循环体抽出,口径逐字不变)。glued=该行由跨 chunk 拼回。 */
61
+ function talkConsumeLine(talk, raw, glued = false) {
62
+ if (glued && raw.includes("\uFFFD")) { talk.dropped = (Number(talk.dropped) || 0) + 1; return; } // 字节级切点:解码损伤不可逆,整行弃但必须留数(根因在 stream.js chunkText 逐 chunk 无状态解码,真修另起 change)
63
+ const t = raw.trim();
64
+ if (!t.startsWith("data:")) return;
65
+ const d = t.slice(5).trim();
66
+ if (!d || d === "[DONE]") return;
67
+ let j = null;
68
+ try { j = JSON.parse(d); } catch { return; }
69
+ const c0 = Array.isArray(j?.choices) ? j.choices[0] : null;
70
+ if (!c0) return;
71
+ for (const src of [c0.delta, c0.message]) {
72
+ if (!src) continue;
73
+ talkPush(talk, "reasoning", src.reasoning_content ?? src.reasoning);
74
+ talkPush(talk, "content", src.content);
75
+ if (Array.isArray(src.tool_calls)) talkMergeToolCalls(talk, src.tool_calls);
76
+ }
77
+ }
78
+
79
+ /** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。跨 chunk 行缓冲见文件头。 */
54
80
  export function captureTalkSse(talk, txt) {
55
81
  if (!talk) return;
56
- for (const raw of String(txt ?? "").split("\n")) {
57
- const t = raw.trim();
58
- if (!t.startsWith("data:")) continue;
59
- const d = t.slice(5).trim();
60
- if (!d || d === "[DONE]") continue;
61
- let j = null;
62
- try { j = JSON.parse(d); } catch { continue; }
63
- const c0 = Array.isArray(j?.choices) ? j.choices[0] : null;
64
- if (!c0) continue;
65
- for (const src of [c0.delta, c0.message]) {
66
- if (!src) continue;
67
- talkPush(talk, "reasoning", src.reasoning_content ?? src.reasoning);
68
- talkPush(talk, "content", src.content);
69
- if (Array.isArray(src.tool_calls)) talkMergeToolCalls(talk, src.tool_calls);
70
- }
71
- }
82
+ if (talk.remain && talk.remain.length > TALK_REMAIN_MAX) { talk.remain = ""; talk.dropped = (Number(talk.dropped) || 0) + 1; } // 拼接前丢弃超限半行:内存不尖峰(review P2-5)且显式记数;本轮正常数据照常消费
83
+ const hadRemain = Boolean(talk.remain);
84
+ const buf = (hadRemain ? talk.remain : "") + String(txt ?? "");
85
+ talk.remain = "";
86
+ if (!buf) return;
87
+ const lines = buf.split("\n");
88
+ if (!buf.endsWith("\n")) talk.remain = lines.pop() ?? "";
89
+ for (let i = 0; i < lines.length; i++) talkConsumeLine(talk, lines[i], hadRemain && i === 0);
90
+ }
91
+
92
+ /** 流收场回收:把缓冲里缺尾换行的末行按同一单行口径入桶(截断流不静默吞末帧)。幂等;无桶/空缓冲零副作用。 */
93
+ export function captureTalkFlush(talk) {
94
+ if (!talk || !talk.remain) return;
95
+ const raw = talk.remain;
96
+ talk.remain = "";
97
+ talkConsumeLine(talk, raw, true);
72
98
  }
73
99
 
74
100
  /** 非流式:整块 message 直接入桶(tool_calls 参数本来就是完整的)。 */
@@ -89,7 +115,7 @@ export function captureTalkFallback(talk, text) {
89
115
 
90
116
  // 逐 SSE chunk 扫描:done/finish_reason/chat 形状/工具调用/usage/正文字符
91
117
  export function scanSseChunk(detail, txt) {
92
- if (detail.talk) captureTalkSse(detail.talk, txt);
118
+ if (detail.talk) { try { captureTalkSse(detail.talk, txt); } catch {} } // 纯旁路红线:捕获异常绝不外溢成转发失败(review P2-7:旧调用点裸奔在 try 外只是口头契约)
93
119
  try {
94
120
  if (txt.includes("[DONE]")) detail.sawDone = true;
95
121
  const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);