mslxdff 0.1.159 → 0.1.160

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 (95) hide show
  1. package/bin/mslxdff.js +4 -4
  2. package/package.json +1 -1
  3. package/src/auto.js +254 -254
  4. package/src/bench/cline-bench.js +42 -42
  5. package/src/bench/probe.js +70 -70
  6. package/src/bench/report.js +162 -162
  7. package/src/bench/runner.js +77 -77
  8. package/src/bench/via-probe.js +124 -124
  9. package/src/bench/via-routes.js +87 -87
  10. package/src/bench/workbuddy-bench.js +54 -54
  11. package/src/chat/engine.js +160 -160
  12. package/src/chat/gateway.js +163 -163
  13. package/src/chat/orchestrator.js +234 -234
  14. package/src/chat/prompt.js +70 -70
  15. package/src/chat/repl.js +88 -88
  16. package/src/chat/terminal.js +135 -135
  17. package/src/chat/tools.js +306 -306
  18. package/src/chat-pipeline/auto-race.js +1 -1
  19. package/src/chat-pipeline/index.js +123 -123
  20. package/src/chat-pipeline/policy.js +76 -76
  21. package/src/chat-pipeline/serial-trial.js +210 -210
  22. package/src/cli/commands/group.js +249 -249
  23. package/src/cli/commands/model/picks.js +50 -50
  24. package/src/cli/commands/provider/bench-via.js +247 -247
  25. package/src/cli/commands/provider/bench.js +141 -141
  26. package/src/cli/commands/provider/index.js +124 -124
  27. package/src/cli/commands/provider/models.js +139 -139
  28. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  29. package/src/cli/commands/stats.js +20 -5
  30. package/src/cli/commands/sync.js +232 -232
  31. package/src/cli/provider-row.js +2 -2
  32. package/src/cli/status.js +279 -279
  33. package/src/daemon.js +96 -96
  34. package/src/model-capabilities/enrich.js +86 -86
  35. package/src/model-capabilities/index.js +183 -183
  36. package/src/model-capabilities/parse.js +70 -70
  37. package/src/models.js +225 -225
  38. package/src/providers/cline/auth.js +228 -228
  39. package/src/providers/cline/chat.js +307 -307
  40. package/src/providers/cline.js +2 -2
  41. package/src/providers/keyring.js +60 -60
  42. package/src/providers/qoder/chat.js +183 -183
  43. package/src/providers/qoder/index.js +230 -230
  44. package/src/providers/qoder/sse.js +103 -103
  45. package/src/providers/qwenwork/account-store.js +133 -133
  46. package/src/providers/qwenwork/constants.js +67 -67
  47. package/src/providers/qwenwork/cosy.js +120 -120
  48. package/src/providers/qwenwork/crypto.js +218 -218
  49. package/src/providers/qwenwork/http.js +20 -20
  50. package/src/providers/qwenwork/index.js +327 -327
  51. package/src/providers/qwenwork/payload.js +142 -142
  52. package/src/providers/qwenwork/rsa.js +54 -54
  53. package/src/providers/qwenwork/sse.js +268 -268
  54. package/src/providers/qwenwork/stream.js +130 -130
  55. package/src/providers/qwenwork/upstream.js +120 -120
  56. package/src/providers/qwenwork.js +1 -1
  57. package/src/providers/registry.js +66 -66
  58. package/src/providers/workbuddy/chat.js +248 -248
  59. package/src/providers/workbuddy/reshape.js +152 -152
  60. package/src/providers/workbuddy.js +2 -2
  61. package/src/providers/zcode/chat.js +11 -10
  62. package/src/providers/zcode/const.js +1 -1
  63. package/src/providers/zcode/context-shape.js +162 -0
  64. package/src/providers/zcode/headers.js +38 -0
  65. package/src/reasoning.js +32 -32
  66. package/src/routes/chat/broadband-handler.js +3 -2
  67. package/src/routes/chat/gateway.js +46 -46
  68. package/src/routes/chat/hedge-handler.js +3 -0
  69. package/src/routes/chat/local-handler.js +2 -0
  70. package/src/routes/chat/peer-handler.js +2 -0
  71. package/src/routes/chat/relay-pipeline.js +264 -250
  72. package/src/routes/chat/via-route-handler.js +146 -144
  73. package/src/routes/hedge.js +255 -255
  74. package/src/routes/models-route.js +167 -167
  75. package/src/routes/peers.js +273 -273
  76. package/src/routes/stream-scan.js +86 -0
  77. package/src/routes/stream.js +393 -438
  78. package/src/runtime/bootstrap.js +45 -45
  79. package/src/runtime/provider-gate.js +33 -33
  80. package/src/runtime/providers-setup.js +165 -165
  81. package/src/server.js +64 -64
  82. package/src/state/schemas/allowlist.js +92 -92
  83. package/src/sync-opencode.js +280 -280
  84. package/src/transport/index.js +244 -244
  85. package/src/transport/pool.js +56 -56
  86. package/src/transport/retry.js +24 -24
  87. package/src/transport/sse.js +93 -93
  88. package/src/upstream-probe/display.js +52 -52
  89. package/src/upstream-probe/probe.js +49 -49
  90. package/src/upstream-probe/rotate.js +110 -110
  91. package/src/upstream-probe/start.js +45 -45
  92. package/src/upstream.js +289 -289
  93. package/src/usage/record.js +8 -1
  94. package/src/usage/report.js +39 -2
  95. package/docs/cli_help_mini.md +0 -133
@@ -1,438 +1,393 @@
1
- import { performance } from "node:perf_hooks";
2
- import { applyFallbackHeaders, enrichNonStreamJson, enrichSseChunkText } from "./fallback.js";
3
- import { json } from "./helpers.js";
4
- import { extractUsageFromJson, extractUsageFromSseText } from "../metrics.js";
5
-
6
- // SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
7
- // 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
8
- function chunkText(chunk) {
9
- if (typeof chunk === "string") return chunk;
10
- if (Buffer.isBuffer(chunk)) return chunk.toString("utf8");
11
- if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf8");
12
- return "";
13
- }
14
-
15
- // body.cancel() 可能返回非 Promise(自定义/AI SDK 流)——同步异常与 rejection 双路径都要吞掉
16
- function cancelBody(body) {
17
- try {
18
- const p = typeof body?.cancel === "function" ? body.cancel() : null;
19
- if (p && typeof p.catch === "function") p.catch(() => {});
20
- } catch { /* ignore */ }
21
- }
22
-
23
- // 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
24
- function hasPayload(text) {
25
- for (const line of String(text).split("\n")) {
26
- const t = line.trim();
27
- if (!t || t.startsWith(":")) continue;
28
- return true;
29
- }
30
- return false;
31
- }
32
-
33
- // 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
34
- // 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
35
- // 否则返回 null(透传)。解析失败一律透传(默认保安全)。
36
- // 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
37
- //(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
38
- // Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
39
- function extractRetryAfterSec(obj) {
40
- if (!obj) return null;
41
- if (typeof obj === "number") return obj;
42
- if (typeof obj === "string") {
43
- const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
44
- if (m) return Number(m[1]);
45
- try {
46
- const parsed = JSON.parse(obj);
47
- return extractRetryAfterSec(parsed);
48
- } catch {}
49
- }
50
- if (typeof obj === "object") {
51
- if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
52
- if (obj.message) {
53
- const r = extractRetryAfterSec(obj.message);
54
- if (r != null) return r;
55
- }
56
- }
57
- return null;
58
- }
59
-
60
- function holdableChunk(txt) {
61
- if (typeof txt !== "string" || !txt.includes("data:")) return null;
62
- let sawData = false;
63
- let sawEventError = false;
64
- let retrySec = null;
65
- const errs = [];
66
- for (const rawLine of txt.split("\n")) {
67
- const t = rawLine.trim();
68
- if (t.startsWith("event:") && t.slice(6).trim() === "error") {
69
- sawEventError = true;
70
- continue;
71
- }
72
- if (!t.startsWith("data:")) continue;
73
- const d = t.slice(5).trim();
74
- if (!d || d === "[DONE]") continue;
75
- let j = null;
76
- try { j = JSON.parse(d); } catch { return null; }
77
- if (!j || typeof j !== "object") return null;
78
- sawData = true;
79
- const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
80
- const delta = c0.delta || {};
81
- const msg = c0.message || {};
82
- const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
83
- if (out && !(Array.isArray(out) && out.length === 0)) return null;
84
-
85
- const r = extractRetryAfterSec(j);
86
- if (r != null) {
87
- retrySec = r;
88
- continue;
89
- }
90
- if (j.error && typeof j.error === "object") {
91
- const m = j.error.message || j.error.code || "";
92
- if (m) errs.push(String(m).slice(0, 300));
93
- } else if (sawEventError && j.message) {
94
- errs.push(String(j.message).slice(0, 300));
95
- } else if (c0.finish_reason === "error") {
96
- errs.push("finish_reason=error 空帧");
97
- }
98
- }
99
- if (!sawData) return null;
100
- if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
101
- if (errs.length) return errs.join(" | ");
102
- if (sawEventError) return "event: error 空帧";
103
- return null;
104
- }
105
-
106
- // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
107
- // 超时/断下游要真能掐上游必须走 reader.cancel()
108
- async function* bodyChunks(body, reader) {
109
- if (reader) {
110
- for (;;) {
111
- const { done, value } = await reader.read();
112
- if (done) return;
113
- yield value;
114
- }
115
- }
116
- for await (const c of body) yield c;
117
- }
118
-
119
- export const SLOW_TOTAL_MS = (() => {
120
- const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
121
- return Number.isInteger(n) && n > 0 ? n : 20_000;
122
- })();
123
-
124
- export const STREAM_TIMEOUT_MS = (() => {
125
- const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
126
- // 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
127
- return Number.isInteger(n) && n >= 0 ? n : 25_000;
128
- })();
129
-
130
- // 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
131
- export const KEEPALIVE_MS = (() => {
132
- const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
133
- return Number.isInteger(n) && n >= 0 ? n : 10_000;
134
- })();
135
-
136
- export const STALL_TIMEOUT_MS = (() => {
137
- const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
138
- return Number.isInteger(n) && n > 0 ? n : 0;
139
- })();
140
-
141
- export const SCORE_STALL_MS = (() => {
142
- const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
143
- const n = Number(raw);
144
- return Number.isInteger(n) && n > 0 ? n : 15_000;
145
- })();
146
-
147
- export const MAX_STREAM_MS = (() => {
148
- const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
149
- return Number.isInteger(n) && n > 0 ? n : 0;
150
- })();
151
-
152
- export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback } = {}) {
153
- const t0 = performance.now();
154
- const contentType = upRes.headers.get("content-type") || "";
155
- // 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
156
- const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
157
- res.statusCode = upRes.status;
158
- // propagate workbuddy uid / allowlist headers
159
- try {
160
- const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
161
- if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
162
- const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
163
- if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
164
- const allow = upRes.headers.get("x-mslxdff-allowlist");
165
- if (allow) res.setHeader("x-mslxdff-allowlist", allow);
166
- const engine = upRes.headers.get("x-mslxdff-upstream-engine");
167
- if (engine) res.setHeader("x-mslxdff-upstream-engine", engine);
168
- } catch {}
169
- if (fallback) applyFallbackHeaders(res, fallback);
170
-
171
- let ttf = null;
172
- let interrupted = false;
173
- let finishedNormally = false;
174
- const detail = {
175
- receivedChunks: 0,
176
- receivedBytes: 0,
177
- wroteChunks: 0,
178
- wroteBytes: 0,
179
- sawDone: false,
180
- sawFinishReason: null,
181
- lastChunkAtMs: null,
182
- lastChunkGapMs: null,
183
- maxGapMs: 0,
184
- stallHits: 0,
185
- exitReason: null,
186
- upstreamError: null,
187
- downstreamClosed: false,
188
- usage: null,
189
- chars: 0,
190
- toolCalls: 0,
191
- chatShaped: false,
192
- heldErrorChunks: 0,
193
- upstreamErrorText: null,
194
- recoveries: 0,
195
- };
196
- let prevChunkAt = t0;
197
- // 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
198
- let cancelUpstream = () => {};
199
- const onClose = () => {
200
- detail.downstreamClosed = true;
201
- if (!finishedNormally) {
202
- cancelUpstream();
203
- if (onDownstreamAbort) onDownstreamAbort();
204
- }
205
- };
206
- res.on("close", onClose);
207
-
208
- if (isStream) {
209
- // failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
210
- if (!res.headersSent) {
211
- try {
212
- res.setHeader("Content-Type", "text/event-stream");
213
- res.setHeader("Cache-Control", "no-cache");
214
- res.setHeader("Connection", "keep-alive");
215
- } catch { /* ignore */ }
216
- }
217
- if (fallback?.fallback) {
218
- try {
219
- res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
220
- res.write(`: notice ${fallback.notice}\n\n`);
221
- } catch {}
222
- }
223
- if (upRes.body) {
224
- let first = true;
225
- let wroteAny = false;
226
- let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
227
- let timedOut = false;
228
- let stalled = false;
229
- let tooLong = false;
230
- let stallTimer = null;
231
- // 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
232
- // Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
233
- const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
234
- let cancelled = false;
235
- cancelUpstream = () => {
236
- if (cancelled) return;
237
- cancelled = true;
238
- if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
239
- else cancelBody(upRes.body);
240
- };
241
- let pingTimer = keepaliveMs > 0
242
- ? setInterval(() => {
243
- if (wroteAny) return;
244
- try { res.write(": keepalive\n\n"); } catch { /* ignore */ }
245
- }, keepaliveMs)
246
- : null;
247
- const armStall = () => {
248
- if (stallTimer) clearTimeout(stallTimer);
249
- stallTimer = STALL_TIMEOUT_MS
250
- ? setTimeout(() => {
251
- stalled = true;
252
- detail.exitReason = "stall";
253
- cancelUpstream();
254
- }, STALL_TIMEOUT_MS)
255
- : null;
256
- };
257
- let firstTimer = streamTimeoutMs > 0
258
- ? setTimeout(() => {
259
- timedOut = true;
260
- detail.exitReason = "first-timeout";
261
- cancelUpstream();
262
- }, streamTimeoutMs)
263
- : null;
264
- const maxTimer = MAX_STREAM_MS
265
- ? setTimeout(() => {
266
- tooLong = true;
267
- detail.exitReason = "max";
268
- cancelUpstream();
269
- }, MAX_STREAM_MS)
270
- : null;
271
- try {
272
- for await (const chunk of bodyChunks(upRes.body, reader)) {
273
- const now = performance.now();
274
- detail.receivedChunks += 1;
275
- const len = chunk?.length ?? chunk?.byteLength ?? 0;
276
- detail.receivedBytes += len;
277
- const gap = Math.round(now - prevChunkAt);
278
- detail.lastChunkAtMs = Math.round(now - t0);
279
- detail.lastChunkGapMs = gap;
280
- if (gap > detail.maxGapMs) detail.maxGapMs = gap;
281
- if (gap > SCORE_STALL_MS) detail.stallHits += 1;
282
- prevChunkAt = now;
283
- const txt = chunkText(chunk);
284
- // 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
285
- // 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
286
- const holdErr = !wrotePayload ? holdableChunk(txt) : null;
287
- if (holdErr) {
288
- detail.heldErrorChunks += 1;
289
- if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
290
- }
291
- const isPayload = hasPayload(txt);
292
- try {
293
- if (txt.includes("[DONE]")) detail.sawDone = true;
294
- const m = txt.match(/"finish_reason"\s*:\s*"([^"]+)"/);
295
- if (m) detail.sawFinishReason = m[1];
296
- // chat 形状证据:只有看得出是 chat 轮才配判空(非 chat SSE/JSON 透传是正式契约,不得误伤)
297
- if (!detail.chatShaped && (txt.includes('"choices"') || txt.includes('"delta"') || txt.includes('"finish_reason"') || txt.includes('"usage"') || txt.includes('"prompt_tokens"') || txt.includes("[DONE]"))) detail.chatShaped = true;
298
- // 工具调用计数(空数组不算):tool_calls 无正文是合法 agent 轮,
299
- // 空转闸门必须豁免它,否则所有工具轮都会被误判为空轮——见 relay-pipeline 4b。
300
- const tc = txt.match(/"tool_calls"\s*:\s*\[\s*\{/g);
301
- if (tc) detail.toolCalls = (detail.toolCalls || 0) + tc.length;
302
- // 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
303
- if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
304
- try {
305
- const lines = txt.split("\n");
306
- for (const line of lines) {
307
- const t = line.trim();
308
- if (!t.startsWith("data:")) continue;
309
- const d = t.slice(5).trim();
310
- if (d === "[DONE]" || !d) continue;
311
- // 行级隔离:单行坏 JSON 不拖累同 chunk 其余行
312
- try {
313
- const j = JSON.parse(d);
314
- if (!j || typeof j !== "object") continue;
315
- const u = extractUsageFromJson(j);
316
- if (u) detail.usage = u;
317
- // 兜底 chars:从 choices 文本长度累加
318
- const delta = j.choices?.[0]?.delta?.content || j.choices?.[0]?.message?.content || "";
319
- if (delta) detail.chars += String(delta).length;
320
- } catch { /* 单行坏帧忽略 */ }
321
- }
322
- } catch {}
323
- } else {
324
- // 非 usage 的普通 delta 也累 chars
325
- try {
326
- const ms = txt.match(/"content"\s*:\s*"([^"]*)"/g);
327
- if (ms) for (const mm of ms) {
328
- const c = JSON.parse(`{${mm}}`);
329
- if (c.content) detail.chars += String(c.content).length;
330
- }
331
- } catch {}
332
- }
333
- } catch { /* ignore */ }
334
- // 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
335
- //(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
336
- // 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
337
- if (isPayload && !holdErr && (timedOut || stalled)) {
338
- timedOut = false;
339
- stalled = false;
340
- detail.recoveries = (detail.recoveries || 0) + 1;
341
- if (detail.exitReason === "first-timeout" || detail.exitReason === "stall") detail.exitReason = null;
342
- if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
343
- }
344
- if (timedOut || stalled || tooLong) break;
345
- if (isPayload && first && !holdErr) {
346
- first = false;
347
- ttf = Math.round(now - t0);
348
- onFirstChunk?.(ttf);
349
- if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
350
- }
351
- let outChunk = chunk;
352
- if (first === false && fallback?.fallback && wrotePayload === false) {
353
- try {
354
- let txt = "";
355
- if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
356
- else if (chunk instanceof Uint8Array) txt = Buffer.from(chunk).toString("utf8");
357
- else if (typeof chunk === "string") txt = chunk;
358
- if (txt.includes("data:")) {
359
- const enriched = enrichSseChunkText(txt, fallback);
360
- if (enriched !== txt) outChunk = Buffer.from(enriched, "utf8");
361
- }
362
- } catch {}
363
- }
364
- if (!holdErr) {
365
- wroteAny = true;
366
- if (isPayload) wrotePayload = true;
367
- detail.wroteChunks += 1;
368
- detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
369
- try { res.write(outChunk); } catch { /* 下游已断开:onClose 已掐上游 */ }
370
- }
371
- armStall();
372
- }
373
- if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
374
- } catch (err) {
375
- detail.upstreamError = String(err?.message || err).slice(0, 300);
376
- detail.exitReason = "upstream-error";
377
- if (!wrotePayload) timedOut = true;
378
- else stalled = true;
379
- } finally {
380
- if (firstTimer) clearTimeout(firstTimer);
381
- if (maxTimer) clearTimeout(maxTimer);
382
- if (stallTimer) clearTimeout(stallTimer);
383
- if (pingTimer) clearInterval(pingTimer);
384
- }
385
- // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
386
- if ((timedOut || stalled || tooLong) && !wrotePayload) {
387
- res.removeListener("close", onClose);
388
- // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
389
- return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, detail };
390
- }
391
- if ((stalled || tooLong) && wrotePayload) {
392
- interrupted = true;
393
- detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
394
- res.removeListener("close", onClose);
395
- try { res.end(); } catch { /* ignore */ }
396
- return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, detail };
397
- }
398
- } else {
399
- detail.exitReason = "empty-body";
400
- }
401
- const totalMs = Math.round(performance.now() - t0);
402
- if (!detail.exitReason) detail.exitReason = "normal";
403
- finishedNormally = true;
404
- res.removeListener("close", onClose);
405
- try { res.end(); } catch { /* ignore */ }
406
- return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, detail };
407
- }
408
-
409
- finishedNormally = true;
410
- res.removeListener("close", onClose);
411
- const text = await upRes.text();
412
- detail.receivedBytes = Buffer.byteLength(text);
413
- detail.exitReason = "normal-non-stream";
414
- // 非流式 usage 与 chars 提取(口径与流式分支共用 metrics.js)
415
- try {
416
- const parsed = JSON.parse(text);
417
- const u = extractUsageFromJson(parsed);
418
- if (u) detail.usage = u;
419
- if (parsed.choices?.[0]?.message?.content) detail.chars = String(parsed.choices[0].message.content).length;
420
- else if (parsed.choices?.[0]?.text) detail.chars = String(parsed.choices[0].text).length;
421
- // 非流式同样只判 chat 形状:无 choices 的任意 JSON 是透传契约(chat-route 单测锁死),不得判空
422
- if (Array.isArray(parsed?.choices)) detail.chatShaped = true;
423
- const _tcList = parsed.choices?.[0]?.message?.tool_calls;
424
- if (Array.isArray(_tcList) && _tcList.length) detail.toolCalls = _tcList.length;
425
- const enriched = enrichNonStreamJson(parsed, fallback);
426
- json(res, upRes.status, enriched);
427
- } catch {
428
- res.statusCode = upRes.status;
429
- res.setHeader("Content-Type", contentType || "text/plain");
430
- res.end(text);
431
- // 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
432
- try { detail.chars = text.length; } catch {}
433
- try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
434
- }
435
- // 非流式 ttf 视为 total(一次性返回)
436
- const totalMs = Math.round(performance.now() - t0);
437
- return { status: upRes.status, timedOut: false, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, detail };
438
- }
1
+ import { performance } from "node:perf_hooks";
2
+ import { applyFallbackHeaders, enrichNonStreamJson, enrichSseChunkText } from "./fallback.js";
3
+ import { json } from "./helpers.js";
4
+ import { extractUsageFromSseText } from "../metrics.js";
5
+ import { scanSseChunk, scanNonStreamBody, preflightMs } from "./stream-scan.js"; // 逐帧/逐体观测累加器 + 上报锚点偏移(纯函数)
6
+ // SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
7
+ // 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
8
+ function chunkText(chunk) {
9
+ if (typeof chunk === "string") return chunk;
10
+ if (Buffer.isBuffer(chunk)) return chunk.toString("utf8");
11
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf8");
12
+ return "";
13
+ }
14
+
15
+ // body.cancel() 可能返回非 Promise(自定义/AI SDK 流)——同步异常与 rejection 双路径都要吞掉
16
+ function cancelBody(body) {
17
+ try {
18
+ const p = typeof body?.cancel === "function" ? body.cancel() : null;
19
+ if (p && typeof p.catch === "function") p.catch(() => {});
20
+ } catch { /* ignore */ }
21
+ }
22
+
23
+ // 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
24
+ function hasPayload(text) {
25
+ for (const line of String(text).split("\n")) {
26
+ const t = line.trim();
27
+ if (!t || t.startsWith(":")) continue;
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // 错误包络 chunk 判定(纯函数):data 行 JSON 含顶层 .error 对象、或只有 finish_reason=error
34
+ // 的空帧,且整 chunk 无任何 content/tool_calls/reasoning 输出 → 返回错误摘要(建议暂扣),
35
+ // 否则返回 null(透传)。解析失败一律透传(默认保安全)。
36
+ // 背景:网关把上游失败包成 HTTP 200 SSE;暂扣后下游流式 UI 不再展示瞬时错误
37
+ //(如 Vertex 503 + google fallback 400),本轮走空转重试,客户端只看到最终结果。
38
+ // Note: 流内错误包络捕获与 retryAfterSeconds 错误直出 — 见 .agents/notes/implemented/bug-fix/2026-09-28-retry-after-surfacing-error.md
39
+ function extractRetryAfterSec(obj) {
40
+ if (!obj) return null;
41
+ if (typeof obj === "number") return obj;
42
+ if (typeof obj === "string") {
43
+ const m = obj.match(/"retryAfterSeconds"\s*:\s*(\d+)/i) || obj.match(/\bretryAfterSeconds\s*[:=]\s*(\d+)/i);
44
+ if (m) return Number(m[1]);
45
+ try {
46
+ const parsed = JSON.parse(obj);
47
+ return extractRetryAfterSec(parsed);
48
+ } catch {}
49
+ }
50
+ if (typeof obj === "object") {
51
+ if (Number.isFinite(obj.retryAfterSeconds)) return Number(obj.retryAfterSeconds);
52
+ if (obj.message) {
53
+ const r = extractRetryAfterSec(obj.message);
54
+ if (r != null) return r;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function holdableChunk(txt) {
61
+ if (typeof txt !== "string" || !txt.includes("data:")) return null;
62
+ let sawData = false;
63
+ let sawEventError = false;
64
+ let retrySec = null;
65
+ const errs = [];
66
+ for (const rawLine of txt.split("\n")) {
67
+ const t = rawLine.trim();
68
+ if (t.startsWith("event:") && t.slice(6).trim() === "error") {
69
+ sawEventError = true;
70
+ continue;
71
+ }
72
+ if (!t.startsWith("data:")) continue;
73
+ const d = t.slice(5).trim();
74
+ if (!d || d === "[DONE]") continue;
75
+ let j = null;
76
+ try { j = JSON.parse(d); } catch { return null; }
77
+ if (!j || typeof j !== "object") return null;
78
+ sawData = true;
79
+ const c0 = (Array.isArray(j.choices) && j.choices[0]) || {};
80
+ const delta = c0.delta || {};
81
+ const msg = c0.message || {};
82
+ const out = delta.content || msg.content || delta.tool_calls || msg.tool_calls || delta.reasoning || msg.reasoning;
83
+ if (out && !(Array.isArray(out) && out.length === 0)) return null;
84
+
85
+ const r = extractRetryAfterSec(j);
86
+ if (r != null) {
87
+ retrySec = r;
88
+ continue;
89
+ }
90
+ if (j.error && typeof j.error === "object") {
91
+ const m = j.error.message || j.error.code || "";
92
+ if (m) errs.push(String(m).slice(0, 300));
93
+ } else if (sawEventError && j.message) {
94
+ errs.push(String(j.message).slice(0, 300));
95
+ } else if (c0.finish_reason === "error") {
96
+ errs.push("finish_reason=error 空帧");
97
+ }
98
+ }
99
+ if (!sawData) return null;
100
+ if (retrySec != null) return `上游供应商触发 retryAfterSeconds: ${retrySec} ,请等候重试`;
101
+ if (errs.length) return errs.join(" | ");
102
+ if (sawEventError) return "event: error 空帧";
103
+ return null;
104
+ }
105
+
106
+ // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
107
+ // 超时/断下游要真能掐上游必须走 reader.cancel()
108
+ async function* bodyChunks(body, reader) {
109
+ if (reader) {
110
+ for (;;) {
111
+ const { done, value } = await reader.read();
112
+ if (done) return;
113
+ yield value;
114
+ }
115
+ }
116
+ for await (const c of body) yield c;
117
+ }
118
+
119
+ export const SLOW_TOTAL_MS = (() => {
120
+ const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
121
+ return Number.isInteger(n) && n > 0 ? n : 20_000;
122
+ })();
123
+
124
+ export const STREAM_TIMEOUT_MS = (() => {
125
+ const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
126
+ // 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
127
+ return Number.isInteger(n) && n >= 0 ? n : 25_000;
128
+ })();
129
+
130
+ // 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
131
+ export const KEEPALIVE_MS = (() => {
132
+ const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
133
+ return Number.isInteger(n) && n >= 0 ? n : 10_000;
134
+ })();
135
+
136
+ export const STALL_TIMEOUT_MS = (() => {
137
+ const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
138
+ return Number.isInteger(n) && n > 0 ? n : 0;
139
+ })();
140
+
141
+ export const SCORE_STALL_MS = (() => {
142
+ const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
143
+ const n = Number(raw);
144
+ return Number.isInteger(n) && n > 0 ? n : 15_000;
145
+ })();
146
+
147
+ export const MAX_STREAM_MS = (() => {
148
+ const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
149
+ return Number.isInteger(n) && n > 0 ? n : 0;
150
+ })();
151
+
152
+ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback, attemptStartMs } = {}) {
153
+ const t0 = performance.now();
154
+ // 上报锚点偏移:只加给「上报用时长」(usage 行),闸门计时仍从上面的 t0 起算,两者不得混用
155
+ const preflight = preflightMs(t0, attemptStartMs);
156
+ const contentType = upRes.headers.get("content-type") || "";
157
+ // 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
158
+ const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
159
+ res.statusCode = upRes.status;
160
+ // propagate workbuddy uid / allowlist headers
161
+ try {
162
+ const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
163
+ if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
164
+ const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
165
+ if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
166
+ const allow = upRes.headers.get("x-mslxdff-allowlist");
167
+ if (allow) res.setHeader("x-mslxdff-allowlist", allow);
168
+ const engine = upRes.headers.get("x-mslxdff-upstream-engine");
169
+ if (engine) res.setHeader("x-mslxdff-upstream-engine", engine);
170
+ } catch {}
171
+ if (fallback) applyFallbackHeaders(res, fallback);
172
+
173
+ let ttf = null;
174
+ let interrupted = false;
175
+ let finishedNormally = false;
176
+ const detail = {
177
+ receivedChunks: 0,
178
+ receivedBytes: 0,
179
+ wroteChunks: 0,
180
+ wroteBytes: 0,
181
+ sawDone: false,
182
+ sawFinishReason: null,
183
+ lastChunkAtMs: null,
184
+ lastChunkGapMs: null,
185
+ maxGapMs: 0,
186
+ stallHits: 0,
187
+ exitReason: null,
188
+ upstreamError: null,
189
+ downstreamClosed: false,
190
+ usage: null,
191
+ chars: 0,
192
+ reasoningChars: 0, // 思考内容字符数(与 chars 分列,相加会重复计数)
193
+ toolCalls: 0,
194
+ chatShaped: false,
195
+ heldErrorChunks: 0,
196
+ upstreamErrorText: null,
197
+ recoveries: 0,
198
+ };
199
+ let prevChunkAt = t0;
200
+ // 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
201
+ let cancelUpstream = () => {};
202
+ const onClose = () => {
203
+ detail.downstreamClosed = true;
204
+ if (!finishedNormally) {
205
+ cancelUpstream();
206
+ if (onDownstreamAbort) onDownstreamAbort();
207
+ }
208
+ };
209
+ res.on("close", onClose);
210
+
211
+ if (isStream) {
212
+ // failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
213
+ if (!res.headersSent) {
214
+ try {
215
+ res.setHeader("Content-Type", "text/event-stream");
216
+ res.setHeader("Cache-Control", "no-cache");
217
+ res.setHeader("Connection", "keep-alive");
218
+ } catch { /* ignore */ }
219
+ }
220
+ if (fallback?.fallback) {
221
+ try {
222
+ res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
223
+ res.write(`: notice ${fallback.notice}\n\n`);
224
+ } catch {}
225
+ }
226
+ if (upRes.body) {
227
+ let first = true;
228
+ let wroteAny = false;
229
+ let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
230
+ let timedOut = false;
231
+ let stalled = false;
232
+ let tooLong = false;
233
+ let stallTimer = null;
234
+ // 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
235
+ // Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
236
+ const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
237
+ let cancelled = false;
238
+ cancelUpstream = () => {
239
+ if (cancelled) return;
240
+ cancelled = true;
241
+ if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
242
+ else cancelBody(upRes.body);
243
+ };
244
+ let pingTimer = keepaliveMs > 0
245
+ ? setInterval(() => {
246
+ if (wroteAny) return;
247
+ try { res.write(": keepalive\n\n"); } catch { /* ignore */ }
248
+ }, keepaliveMs)
249
+ : null;
250
+ const armStall = () => {
251
+ if (stallTimer) clearTimeout(stallTimer);
252
+ stallTimer = STALL_TIMEOUT_MS
253
+ ? setTimeout(() => {
254
+ stalled = true;
255
+ detail.exitReason = "stall";
256
+ cancelUpstream();
257
+ }, STALL_TIMEOUT_MS)
258
+ : null;
259
+ };
260
+ let firstTimer = streamTimeoutMs > 0
261
+ ? setTimeout(() => {
262
+ timedOut = true;
263
+ detail.exitReason = "first-timeout";
264
+ cancelUpstream();
265
+ }, streamTimeoutMs)
266
+ : null;
267
+ const maxTimer = MAX_STREAM_MS
268
+ ? setTimeout(() => {
269
+ tooLong = true;
270
+ detail.exitReason = "max";
271
+ cancelUpstream();
272
+ }, MAX_STREAM_MS)
273
+ : null;
274
+ try {
275
+ for await (const chunk of bodyChunks(upRes.body, reader)) {
276
+ const now = performance.now();
277
+ detail.receivedChunks += 1;
278
+ const len = chunk?.length ?? chunk?.byteLength ?? 0;
279
+ detail.receivedBytes += len;
280
+ const gap = Math.round(now - prevChunkAt);
281
+ detail.lastChunkAtMs = Math.round(now - t0);
282
+ detail.lastChunkGapMs = gap;
283
+ if (gap > detail.maxGapMs) detail.maxGapMs = gap;
284
+ if (gap > SCORE_STALL_MS) detail.stallHits += 1;
285
+ prevChunkAt = now;
286
+ const txt = chunkText(chunk);
287
+ // 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
288
+ // 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
289
+ const holdErr = !wrotePayload ? holdableChunk(txt) : null;
290
+ if (holdErr) {
291
+ detail.heldErrorChunks += 1;
292
+ if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
293
+ }
294
+ const isPayload = hasPayload(txt);
295
+ scanSseChunk(detail, txt);
296
+ // 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
297
+ //(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
298
+ // 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
299
+ if (isPayload && !holdErr && (timedOut || stalled)) {
300
+ timedOut = false;
301
+ stalled = false;
302
+ detail.recoveries = (detail.recoveries || 0) + 1;
303
+ if (detail.exitReason === "first-timeout" || detail.exitReason === "stall") detail.exitReason = null;
304
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
305
+ }
306
+ if (timedOut || stalled || tooLong) break;
307
+ if (isPayload && first && !holdErr) {
308
+ first = false;
309
+ ttf = Math.round(now - t0);
310
+ onFirstChunk?.(ttf);
311
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
312
+ }
313
+ let outChunk = chunk;
314
+ if (first === false && fallback?.fallback && wrotePayload === false) {
315
+ try {
316
+ let txt = "";
317
+ if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
318
+ else if (chunk instanceof Uint8Array) txt = Buffer.from(chunk).toString("utf8");
319
+ else if (typeof chunk === "string") txt = chunk;
320
+ if (txt.includes("data:")) {
321
+ const enriched = enrichSseChunkText(txt, fallback);
322
+ if (enriched !== txt) outChunk = Buffer.from(enriched, "utf8");
323
+ }
324
+ } catch {}
325
+ }
326
+ if (!holdErr) {
327
+ wroteAny = true;
328
+ if (isPayload) wrotePayload = true;
329
+ detail.wroteChunks += 1;
330
+ detail.wroteBytes += Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
331
+ try { res.write(outChunk); } catch { /* 下游已断开:onClose 已掐上游 */ }
332
+ }
333
+ armStall();
334
+ }
335
+ if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
336
+ } catch (err) {
337
+ detail.upstreamError = String(err?.message || err).slice(0, 300);
338
+ detail.exitReason = "upstream-error";
339
+ if (!wrotePayload) timedOut = true;
340
+ else stalled = true;
341
+ } finally {
342
+ if (firstTimer) clearTimeout(firstTimer);
343
+ if (maxTimer) clearTimeout(maxTimer);
344
+ if (stallTimer) clearTimeout(stallTimer);
345
+ if (pingTimer) clearInterval(pingTimer);
346
+ }
347
+ // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
348
+ if ((timedOut || stalled || tooLong) && !wrotePayload) {
349
+ res.removeListener("close", onClose);
350
+ // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
351
+ return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, preflightMs: preflight, detail };
352
+ }
353
+ if ((stalled || tooLong) && wrotePayload) {
354
+ interrupted = true;
355
+ detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
356
+ res.removeListener("close", onClose);
357
+ try { res.end(); } catch { /* ignore */ }
358
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, preflightMs: preflight, detail };
359
+ }
360
+ } else {
361
+ detail.exitReason = "empty-body";
362
+ }
363
+ const totalMs = Math.round(performance.now() - t0);
364
+ if (!detail.exitReason) detail.exitReason = "normal";
365
+ finishedNormally = true;
366
+ res.removeListener("close", onClose);
367
+ try { res.end(); } catch { /* ignore */ }
368
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, preflightMs: preflight, detail };
369
+ }
370
+
371
+ finishedNormally = true;
372
+ res.removeListener("close", onClose);
373
+ const text = await upRes.text();
374
+ detail.receivedBytes = Buffer.byteLength(text);
375
+ detail.exitReason = "normal-non-stream";
376
+ // 非流式 usage 与 chars 提取(口径与流式分支共用 metrics.js)
377
+ try {
378
+ const parsed = JSON.parse(text);
379
+ scanNonStreamBody(detail, parsed);
380
+ const enriched = enrichNonStreamJson(parsed, fallback);
381
+ json(res, upRes.status, enriched);
382
+ } catch {
383
+ res.statusCode = upRes.status;
384
+ res.setHeader("Content-Type", contentType || "text/plain");
385
+ res.end(text);
386
+ // 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
387
+ try { detail.chars = text.length; } catch {}
388
+ try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
389
+ }
390
+ // 非流式 ttf 视为 total(一次性返回)
391
+ const totalMs = Math.round(performance.now() - t0);
392
+ return { status: upRes.status, timedOut: false, ttfMs: totalMs, totalMs, aborted: false, interrupted: false, preflightMs: preflight, detail };
393
+ }