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,385 +1,385 @@
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, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk, extractRetryAfterSec, isTrivialFrame } from "./stream-scan.js"; // 逐帧/逐体观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣 + 可撤销前缀判据(纯函数与建桶判据全在 stream-scan)
6
- import { holdEndEnabled, createRevocablePrefix, endEmptyTurnStream } from "./stream-hold.js"; // 空轮可撤销前缀 / 延后封口 / 终局收场(拆出去是为了不破 stream.js 20KB 硬门)
7
- // SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
8
- // 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
9
- function chunkText(chunk) {
10
- if (typeof chunk === "string") return chunk;
11
- if (Buffer.isBuffer(chunk)) return chunk.toString("utf8");
12
- if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf8");
13
- return "";
14
- }
15
-
16
- // body.cancel() 可能返回非 Promise(自定义/AI SDK 流)——同步异常与 rejection 双路径都要吞掉
17
- function cancelBody(body) {
18
- try {
19
- const p = typeof body?.cancel === "function" ? body.cancel() : null;
20
- if (p && typeof p.catch === "function") p.catch(() => {});
21
- } catch { /* ignore */ }
22
- }
23
-
24
- // 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
25
- function hasPayload(text) {
26
- for (const line of String(text).split("\n")) {
27
- const t = line.trim();
28
- if (!t || t.startsWith(":")) continue;
29
- return true;
30
- }
31
- return false;
32
- }
33
-
34
- // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
35
- // 超时/断下游要真能掐上游必须走 reader.cancel()
36
- async function* bodyChunks(body, reader) {
37
- if (reader) {
38
- for (;;) {
39
- const { done, value } = await reader.read();
40
- if (done) return;
41
- yield value;
42
- }
43
- }
44
- for await (const c of body) yield c;
45
- }
46
-
47
- export const SLOW_TOTAL_MS = (() => {
48
- const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
49
- return Number.isInteger(n) && n > 0 ? n : 20_000;
50
- })();
51
-
52
- export const STREAM_TIMEOUT_MS = (() => {
53
- const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
54
- // 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
55
- return Number.isInteger(n) && n >= 0 ? n : 25_000;
56
- })();
57
-
58
- // 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
59
- export const KEEPALIVE_MS = (() => {
60
- const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
61
- return Number.isInteger(n) && n >= 0 ? n : 10_000;
62
- })();
63
-
64
- export const STALL_TIMEOUT_MS = (() => {
65
- const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
66
- return Number.isInteger(n) && n > 0 ? n : 0;
67
- })();
68
-
69
- export const SCORE_STALL_MS = (() => {
70
- const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
71
- const n = Number(raw);
72
- return Number.isInteger(n) && n > 0 ? n : 15_000;
73
- })();
74
-
75
- export const MAX_STREAM_MS = (() => {
76
- const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
77
- return Number.isInteger(n) && n > 0 ? n : 0;
78
- })();
79
-
80
- export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback, attemptStartMs } = {}) {
81
- const t0 = performance.now();
82
- // 上报锚点偏移:只加给「上报用时长」(usage 行),闸门计时仍从上面的 t0 起算,两者不得混用
83
- const preflight = preflightMs(t0, attemptStartMs);
84
- const contentType = upRes.headers.get("content-type") || "";
85
- // 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
86
- const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
87
- // 重入同一条连接(空轮留口后同模型重拉 / 换候选)时 headers 可能已 flush(前一发发过 keepalive 注释帧):
88
- // 此时 statusCode 与自定义头都不可再设,否则 setHeader 抛 ERR_HTTP_HEADERS_SENT,重拉直接炸在半路
89
- if (!res.headersSent) {
90
- res.statusCode = upRes.status;
91
- // propagate workbuddy uid / allowlist headers
92
- try {
93
- const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
94
- if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
95
- const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
96
- if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
97
- const allow = upRes.headers.get("x-mslxdff-allowlist");
98
- if (allow) res.setHeader("x-mslxdff-allowlist", allow);
99
- const engine = upRes.headers.get("x-mslxdff-upstream-engine");
100
- if (engine) res.setHeader("x-mslxdff-upstream-engine", engine);
101
- } catch {}
102
- if (fallback) applyFallbackHeaders(res, fallback);
103
- }
104
-
105
- let ttf = null;
106
- let interrupted = false;
107
- let finishedNormally = false;
108
- let commitPendingPrefix = () => {};
109
- let flushHeldTail = () => {}; // 把暂扣的 [DONE] 补写出去(错误帧必须能先站在它前面)
110
- const detail = {
111
- receivedChunks: 0,
112
- receivedBytes: 0,
113
- wroteChunks: 0,
114
- wroteBytes: 0,
115
- sawDone: false,
116
- sawFinishReason: null,
117
- lastChunkAtMs: null,
118
- lastChunkGapMs: null,
119
- maxGapMs: 0,
120
- stallHits: 0,
121
- exitReason: null,
122
- upstreamError: null,
123
- downstreamClosed: false,
124
- usage: null,
125
- chars: 0,
126
- reasoningChars: 0, // 思考内容字符数(与 chars 分列,相加会重复计数)
127
- toolCalls: 0,
128
- chatShaped: false,
129
- talk: createTalkBucketIfEnabled(), // 正文桶:talk.log 或回路捕获任一开就建,上限单一真相 talkCapChars()
130
- heldErrorChunks: 0,
131
- upstreamErrorText: null,
132
- recoveries: 0,
133
- wrotePayload: false, // 是否已向下游写出真实数据帧(注释帧不算):决定空转还能不能靠重试救
134
- };
135
- let prevChunkAt = t0;
136
- // 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
137
- let cancelUpstream = () => {};
138
- const onClose = () => {
139
- detail.downstreamClosed = true;
140
- if (!finishedNormally) {
141
- cancelUpstream();
142
- if (onDownstreamAbort) onDownstreamAbort();
143
- }
144
- };
145
- res.on("close", onClose);
146
- // 入口即已断开/已收场:先落账,否则这一发会把留口当成「下游还活着」,重拉写给消失的读者
147
- if (res.writableEnded || res.destroyed) detail.downstreamClosed = true;
148
-
149
- if (isStream) {
150
- // failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
151
- if (!res.headersSent) {
152
- try {
153
- res.setHeader("Content-Type", "text/event-stream");
154
- res.setHeader("Cache-Control", "no-cache");
155
- res.setHeader("Connection", "keep-alive");
156
- } catch { /* ignore */ }
157
- }
158
- if (fallback?.fallback) {
159
- try {
160
- res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
161
- res.write(`: notice ${fallback.notice}\n\n`);
162
- } catch {}
163
- }
164
- if (upRes.body) {
165
- let first = true;
166
- let wroteAny = false;
167
- let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
168
- const revocableHold = holdEndEnabled(); // HOLD_END=0 时连缓冲都不做(逐字节复现旧行为,含时机)
169
- // 可撤销前缀缓冲(策略见 stream-hold.js):模型产出出现前,空 delta 帧与 [DONE] 先不入下游,
170
- // 这样同模型重拉 / 换候选的正文还能顺着同一条连接送出去;上限兜底防异常形状囤内存。
171
- const prefix = createRevocablePrefix(res, detail);
172
- commitPendingPrefix = () => prefix.commit();
173
- flushHeldTail = () => prefix.flushTail();
174
- let timedOut = false;
175
- let stalled = false;
176
- let tooLong = false;
177
- let stallTimer = null;
178
- // 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
179
- // Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
180
- const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
181
- let cancelled = false;
182
- cancelUpstream = () => {
183
- if (cancelled) return;
184
- cancelled = true;
185
- if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
186
- else cancelBody(upRes.body);
187
- };
188
- let pingTimer = keepaliveMs > 0
189
- ? setInterval(() => {
190
- if (wroteAny) return;
191
- try { res.write(": keepalive\n\n"); } catch { /* ignore */ }
192
- }, keepaliveMs)
193
- : null;
194
- const armStall = () => {
195
- if (stallTimer) clearTimeout(stallTimer);
196
- stallTimer = STALL_TIMEOUT_MS
197
- ? setTimeout(() => {
198
- stalled = true;
199
- detail.exitReason = "stall";
200
- cancelUpstream();
201
- }, STALL_TIMEOUT_MS)
202
- : null;
203
- };
204
- let firstTimer = streamTimeoutMs > 0
205
- ? setTimeout(() => {
206
- timedOut = true;
207
- detail.exitReason = "first-timeout";
208
- cancelUpstream();
209
- }, streamTimeoutMs)
210
- : null;
211
- const maxTimer = MAX_STREAM_MS
212
- ? setTimeout(() => {
213
- tooLong = true;
214
- detail.exitReason = "max";
215
- cancelUpstream();
216
- }, MAX_STREAM_MS)
217
- : null;
218
- try {
219
- for await (const chunk of bodyChunks(upRes.body, reader)) {
220
- const now = performance.now();
221
- detail.receivedChunks += 1;
222
- const len = chunk?.length ?? chunk?.byteLength ?? 0;
223
- detail.receivedBytes += len;
224
- const gap = Math.round(now - prevChunkAt);
225
- detail.lastChunkAtMs = Math.round(now - t0);
226
- detail.lastChunkGapMs = gap;
227
- if (gap > detail.maxGapMs) detail.maxGapMs = gap;
228
- if (gap > SCORE_STALL_MS) detail.stallHits += 1;
229
- prevChunkAt = now;
230
- const txt = chunkText(chunk);
231
- // 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
232
- // 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
233
- const holdErr = !wrotePayload ? holdableChunk(txt) : null;
234
- if (holdErr) {
235
- detail.heldErrorChunks += 1;
236
- if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
237
- }
238
- const isPayload = hasPayload(txt);
239
- const prevChars = detail.chars, prevTools = detail.toolCalls, prevReason = detail.reasoningChars;
240
- scanSseChunk(detail, txt);
241
- // 「模型产出」=正文/工具调用/思考三者本轮有新增。思考也算:已发出的思考撤不回,
242
- // 从它出现的那刻起这次尝试就提交了(现网空轮多数正是「思考刷满、正文为零」)。
243
- const gained = Number(detail.chars) > prevChars || Number(detail.toolCalls) > prevTools || Number(detail.reasoningChars) > prevReason;
244
- // 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
245
- //(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
246
- // 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
247
- if (isPayload && !holdErr && (timedOut || stalled)) {
248
- timedOut = false;
249
- stalled = false;
250
- detail.recoveries = (detail.recoveries || 0) + 1;
251
- if (detail.exitReason === "first-timeout" || detail.exitReason === "stall") detail.exitReason = null;
252
- if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
253
- }
254
- if (timedOut || stalled || tooLong) break;
255
- if (isPayload && first && !holdErr) {
256
- first = false;
257
- ttf = Math.round(now - t0);
258
- onFirstChunk?.(ttf);
259
- if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
260
- }
261
- let outChunk = chunk;
262
- if (first === false && fallback?.fallback && wrotePayload === false) {
263
- try {
264
- let txt = "";
265
- if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
266
- else if (chunk instanceof Uint8Array) txt = Buffer.from(chunk).toString("utf8");
267
- else if (typeof chunk === "string") txt = chunk;
268
- if (txt.includes("data:")) {
269
- const enriched = enrichSseChunkText(txt, fallback);
270
- if (enriched !== txt) outChunk = Buffer.from(enriched, "utf8");
271
- }
272
- } catch {}
273
- }
274
- if (!holdErr) {
275
- const size = Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
276
- if (!isPayload) {
277
- // 注释帧等非数据帧:与改前一致直写(客户端视作噪声,不影响可撤销性)
278
- wroteAny = true;
279
- prefix.writeNow(outChunk, size);
280
- } else if (gained || !revocableHold || !isTrivialFrame(txt)) {
281
- // 有模型产出(正文/工具/思考)、留口已关、或看不懂的异形帧:按序补写暂扣前缀 + 本帧,
282
- // 这条流就此提交不可撤销(透传契约要求解析失败的帧必须原样走,绝不为救空轮扣下)
283
- wroteAny = true;
284
- wrotePayload = true; detail.wrotePayload = true;
285
- prefix.submit(outChunk, size);
286
- } else if (!prefix.hold(outChunk, size, txt.includes("[DONE]"))) {
287
- // 可撤销前缀:空 delta 暂扣(判为空轮时整段撤销,留给重拉的正文);
288
- // [DONE] 无论是否已提交都进 tail 槽 —— 错误帧必须能排在它前面,否则客户端一见 [DONE] 就收尾
289
- wroteAny = true;
290
- wrotePayload = true; detail.wrotePayload = true;
291
- prefix.submit(outChunk, size); // 暂扣超上限(异常形状)→ 被迫提交,退回旧行为
292
- }
293
- }
294
- armStall();
295
- }
296
- if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
297
- } catch (err) {
298
- detail.upstreamError = String(err?.message || err).slice(0, 300);
299
- detail.exitReason = "upstream-error";
300
- if (!wrotePayload) timedOut = true;
301
- else stalled = true;
302
- } finally {
303
- if (firstTimer) clearTimeout(firstTimer);
304
- if (maxTimer) clearTimeout(maxTimer);
305
- if (stallTimer) clearTimeout(stallTimer);
306
- if (pingTimer) clearInterval(pingTimer);
307
- }
308
- // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
309
- if ((timedOut || stalled || tooLong) && !wrotePayload) {
310
- res.removeListener("close", onClose);
311
- // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
312
- return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, preflightMs: preflight, detail };
313
- }
314
- if ((stalled || tooLong) && wrotePayload) {
315
- interrupted = true;
316
- detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
317
- flushHeldTail(); // 断流收场也要把 [DONE] 按序补上,别让客户端等一个不存在的终止符
318
- try { res.end(); } catch { /* ignore */ }
319
- return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, preflightMs: preflight, detail };
320
- }
321
- } else {
322
- detail.exitReason = "empty-body";
323
- }
324
- const totalMs = Math.round(performance.now() - t0);
325
- if (!detail.exitReason) detail.exitReason = "normal";
326
- finishedNormally = true;
327
- // 留口期间不摘 close 监听(close 只发一次,摘了客户端窗口内断开就永久盲窗、照烧额度)
328
- const heldOpen = holdEndEnabled() && detail.wrotePayload !== true && !detail.downstreamClosed && isEmptyTurnDetail(detail);
329
- if (!heldOpen) res.removeListener("close", onClose);
330
- let terminalForm = null;
331
- if (heldOpen) {
332
- detail.exitReason = "empty-turn-hold";
333
- } else {
334
- if (!detail.downstreamClosed) {
335
- commitPendingPrefix(); // 不留口 = 补写暂扣前缀(HOLD_END=0 时逐字节复现改前行为)
336
- // 思考刷满 max_tokens、正文为零(现网主流):思考撤不回,错误帧必须排在 [DONE] 之前才看得见
337
- const reasoningOnly = Number(detail.chars) === 0 && Number(detail.toolCalls) === 0 && Number(detail.reasoningChars) > 0;
338
- if (reasoningOnly && holdEndEnabled()) {
339
- detail.exitReason = "reasoning-only";
340
- terminalForm = "sse-error-tail";
341
- endEmptyTurnStream(res, `EMPTY_MODEL_RESPONSE: 只产出思考 ${detail.reasoningChars} 字、正文为零(额度被思考吃满),已重试仍无正文 — raise max_tokens or rephrase`, () => flushHeldTail());
342
- } else {
343
- flushHeldTail(); // 没有错误帧要插队,[DONE] 按原序发出
344
- try { res.end(); } catch { /* ignore */ }
345
- }
346
- } else {
347
- try { res.end(); } catch { /* ignore */ }
348
- }
349
- }
350
- return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, heldOpen, terminalForm, preflightMs: preflight, detail };
351
- }
352
-
353
- finishedNormally = true;
354
- res.removeListener("close", onClose);
355
- const text = await upRes.text();
356
- detail.receivedBytes = Buffer.byteLength(text);
357
- detail.exitReason = "normal-non-stream";
358
- // 非流式同样「先判后写」:此刻 headers 还没 flush,body 一写下去就再无退路。
359
- let enriched = null;
360
- try {
361
- const parsed = JSON.parse(text);
362
- scanNonStreamBody(detail, parsed);
363
- enriched = enrichNonStreamJson(parsed, fallback);
364
- } catch { /* 上游给的不是可解析 JSON:走下面的原文透传 */ }
365
- if (enriched === null) {
366
- // 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
367
- try { detail.chars = text.length; } catch {}
368
- try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
369
- try { if (detail.talk) captureTalkFallback(detail.talk, text); } catch {}
370
- } else if (holdEndEnabled() && !detail.downstreamClosed && isEmptyTurnDetail(detail)) {
371
- detail.exitReason = "empty-turn-hold";
372
- return { status: upRes.status, timedOut: false, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted: false, heldOpen: true, preflightMs: preflight, detail };
373
- }
374
- detail.wrotePayload = true; // 真写下去了:从此撤不回,也不再判空轮
375
- const nsTotalMs = Math.round(performance.now() - t0);
376
- if (enriched !== null) {
377
- json(res, upRes.status, enriched);
378
- } else {
379
- res.statusCode = upRes.status;
380
- res.setHeader("Content-Type", contentType || "text/plain");
381
- res.end(text);
382
- }
383
- // 非流式 ttf 视为 total(一次性返回)
384
- return { status: upRes.status, timedOut: false, ttfMs: nsTotalMs, totalMs: nsTotalMs, aborted: false, interrupted: false, heldOpen: false, preflightMs: preflight, detail };
385
- }
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, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk, extractRetryAfterSec, isTrivialFrame } from "./stream-scan.js"; // 逐帧/逐体观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣 + 可撤销前缀判据(纯函数与建桶判据全在 stream-scan)
6
+ import { holdEndEnabled, createRevocablePrefix, endEmptyTurnStream } from "./stream-hold.js"; // 空轮可撤销前缀 / 延后封口 / 终局收场(拆出去是为了不破 stream.js 20KB 硬门)
7
+ // SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
8
+ // 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
9
+ function chunkText(chunk) {
10
+ if (typeof chunk === "string") return chunk;
11
+ if (Buffer.isBuffer(chunk)) return chunk.toString("utf8");
12
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf8");
13
+ return "";
14
+ }
15
+
16
+ // body.cancel() 可能返回非 Promise(自定义/AI SDK 流)——同步异常与 rejection 双路径都要吞掉
17
+ function cancelBody(body) {
18
+ try {
19
+ const p = typeof body?.cancel === "function" ? body.cancel() : null;
20
+ if (p && typeof p.catch === "function") p.catch(() => {});
21
+ } catch { /* ignore */ }
22
+ }
23
+
24
+ // 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
25
+ function hasPayload(text) {
26
+ for (const line of String(text).split("\n")) {
27
+ const t = line.trim();
28
+ if (!t || t.startsWith(":")) continue;
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ // 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
35
+ // 超时/断下游要真能掐上游必须走 reader.cancel()
36
+ async function* bodyChunks(body, reader) {
37
+ if (reader) {
38
+ for (;;) {
39
+ const { done, value } = await reader.read();
40
+ if (done) return;
41
+ yield value;
42
+ }
43
+ }
44
+ for await (const c of body) yield c;
45
+ }
46
+
47
+ export const SLOW_TOTAL_MS = (() => {
48
+ const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
49
+ return Number.isInteger(n) && n > 0 ? n : 20_000;
50
+ })();
51
+
52
+ export const STREAM_TIMEOUT_MS = (() => {
53
+ const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
54
+ // 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
55
+ return Number.isInteger(n) && n >= 0 ? n : 25_000;
56
+ })();
57
+
58
+ // 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
59
+ export const KEEPALIVE_MS = (() => {
60
+ const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
61
+ return Number.isInteger(n) && n >= 0 ? n : 10_000;
62
+ })();
63
+
64
+ export const STALL_TIMEOUT_MS = (() => {
65
+ const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
66
+ return Number.isInteger(n) && n > 0 ? n : 0;
67
+ })();
68
+
69
+ export const SCORE_STALL_MS = (() => {
70
+ const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
71
+ const n = Number(raw);
72
+ return Number.isInteger(n) && n > 0 ? n : 15_000;
73
+ })();
74
+
75
+ export const MAX_STREAM_MS = (() => {
76
+ const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
77
+ return Number.isInteger(n) && n > 0 ? n : 0;
78
+ })();
79
+
80
+ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback, attemptStartMs } = {}) {
81
+ const t0 = performance.now();
82
+ // 上报锚点偏移:只加给「上报用时长」(usage 行),闸门计时仍从上面的 t0 起算,两者不得混用
83
+ const preflight = preflightMs(t0, attemptStartMs);
84
+ const contentType = upRes.headers.get("content-type") || "";
85
+ // 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
86
+ const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
87
+ // 重入同一条连接(空轮留口后同模型重拉 / 换候选)时 headers 可能已 flush(前一发发过 keepalive 注释帧):
88
+ // 此时 statusCode 与自定义头都不可再设,否则 setHeader 抛 ERR_HTTP_HEADERS_SENT,重拉直接炸在半路
89
+ if (!res.headersSent) {
90
+ res.statusCode = upRes.status;
91
+ // propagate workbuddy uid / allowlist headers
92
+ try {
93
+ const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
94
+ if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
95
+ const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
96
+ if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
97
+ const allow = upRes.headers.get("x-mslxdff-allowlist");
98
+ if (allow) res.setHeader("x-mslxdff-allowlist", allow);
99
+ const engine = upRes.headers.get("x-mslxdff-upstream-engine");
100
+ if (engine) res.setHeader("x-mslxdff-upstream-engine", engine);
101
+ } catch {}
102
+ if (fallback) applyFallbackHeaders(res, fallback);
103
+ }
104
+
105
+ let ttf = null;
106
+ let interrupted = false;
107
+ let finishedNormally = false;
108
+ let commitPendingPrefix = () => {};
109
+ let flushHeldTail = () => {}; // 把暂扣的 [DONE] 补写出去(错误帧必须能先站在它前面)
110
+ const detail = {
111
+ receivedChunks: 0,
112
+ receivedBytes: 0,
113
+ wroteChunks: 0,
114
+ wroteBytes: 0,
115
+ sawDone: false,
116
+ sawFinishReason: null,
117
+ lastChunkAtMs: null,
118
+ lastChunkGapMs: null,
119
+ maxGapMs: 0,
120
+ stallHits: 0,
121
+ exitReason: null,
122
+ upstreamError: null,
123
+ downstreamClosed: false,
124
+ usage: null,
125
+ chars: 0,
126
+ reasoningChars: 0, // 思考内容字符数(与 chars 分列,相加会重复计数)
127
+ toolCalls: 0,
128
+ chatShaped: false,
129
+ talk: createTalkBucketIfEnabled(), // 正文桶:talk.log 或回路捕获任一开就建,上限单一真相 talkCapChars()
130
+ heldErrorChunks: 0,
131
+ upstreamErrorText: null,
132
+ recoveries: 0,
133
+ wrotePayload: false, // 是否已向下游写出真实数据帧(注释帧不算):决定空转还能不能靠重试救
134
+ };
135
+ let prevChunkAt = t0;
136
+ // 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
137
+ let cancelUpstream = () => {};
138
+ const onClose = () => {
139
+ detail.downstreamClosed = true;
140
+ if (!finishedNormally) {
141
+ cancelUpstream();
142
+ if (onDownstreamAbort) onDownstreamAbort();
143
+ }
144
+ };
145
+ res.on("close", onClose);
146
+ // 入口即已断开/已收场:先落账,否则这一发会把留口当成「下游还活着」,重拉写给消失的读者
147
+ if (res.writableEnded || res.destroyed) detail.downstreamClosed = true;
148
+
149
+ if (isStream) {
150
+ // failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
151
+ if (!res.headersSent) {
152
+ try {
153
+ res.setHeader("Content-Type", "text/event-stream");
154
+ res.setHeader("Cache-Control", "no-cache");
155
+ res.setHeader("Connection", "keep-alive");
156
+ } catch { /* ignore */ }
157
+ }
158
+ if (fallback?.fallback) {
159
+ try {
160
+ res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
161
+ res.write(`: notice ${fallback.notice}\n\n`);
162
+ } catch {}
163
+ }
164
+ if (upRes.body) {
165
+ let first = true;
166
+ let wroteAny = false;
167
+ let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
168
+ const revocableHold = holdEndEnabled(); // HOLD_END=0 时连缓冲都不做(逐字节复现旧行为,含时机)
169
+ // 可撤销前缀缓冲(策略见 stream-hold.js):模型产出出现前,空 delta 帧与 [DONE] 先不入下游,
170
+ // 这样同模型重拉 / 换候选的正文还能顺着同一条连接送出去;上限兜底防异常形状囤内存。
171
+ const prefix = createRevocablePrefix(res, detail);
172
+ commitPendingPrefix = () => prefix.commit();
173
+ flushHeldTail = () => prefix.flushTail();
174
+ let timedOut = false;
175
+ let stalled = false;
176
+ let tooLong = false;
177
+ let stallTimer = null;
178
+ // 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
179
+ // Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
180
+ const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
181
+ let cancelled = false;
182
+ cancelUpstream = () => {
183
+ if (cancelled) return;
184
+ cancelled = true;
185
+ if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
186
+ else cancelBody(upRes.body);
187
+ };
188
+ let pingTimer = keepaliveMs > 0
189
+ ? setInterval(() => {
190
+ if (wroteAny) return;
191
+ try { res.write(": keepalive\n\n"); } catch { /* ignore */ }
192
+ }, keepaliveMs)
193
+ : null;
194
+ const armStall = () => {
195
+ if (stallTimer) clearTimeout(stallTimer);
196
+ stallTimer = STALL_TIMEOUT_MS
197
+ ? setTimeout(() => {
198
+ stalled = true;
199
+ detail.exitReason = "stall";
200
+ cancelUpstream();
201
+ }, STALL_TIMEOUT_MS)
202
+ : null;
203
+ };
204
+ let firstTimer = streamTimeoutMs > 0
205
+ ? setTimeout(() => {
206
+ timedOut = true;
207
+ detail.exitReason = "first-timeout";
208
+ cancelUpstream();
209
+ }, streamTimeoutMs)
210
+ : null;
211
+ const maxTimer = MAX_STREAM_MS
212
+ ? setTimeout(() => {
213
+ tooLong = true;
214
+ detail.exitReason = "max";
215
+ cancelUpstream();
216
+ }, MAX_STREAM_MS)
217
+ : null;
218
+ try {
219
+ for await (const chunk of bodyChunks(upRes.body, reader)) {
220
+ const now = performance.now();
221
+ detail.receivedChunks += 1;
222
+ const len = chunk?.length ?? chunk?.byteLength ?? 0;
223
+ detail.receivedBytes += len;
224
+ const gap = Math.round(now - prevChunkAt);
225
+ detail.lastChunkAtMs = Math.round(now - t0);
226
+ detail.lastChunkGapMs = gap;
227
+ if (gap > detail.maxGapMs) detail.maxGapMs = gap;
228
+ if (gap > SCORE_STALL_MS) detail.stallHits += 1;
229
+ prevChunkAt = now;
230
+ const txt = chunkText(chunk);
231
+ // 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
232
+ // 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
233
+ const holdErr = !wrotePayload ? holdableChunk(txt) : null;
234
+ if (holdErr) {
235
+ detail.heldErrorChunks += 1;
236
+ if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
237
+ }
238
+ const isPayload = hasPayload(txt);
239
+ const prevChars = detail.chars, prevTools = detail.toolCalls, prevReason = detail.reasoningChars;
240
+ scanSseChunk(detail, txt);
241
+ // 「模型产出」=正文/工具调用/思考三者本轮有新增。思考也算:已发出的思考撤不回,
242
+ // 从它出现的那刻起这次尝试就提交了(现网空轮多数正是「思考刷满、正文为零」)。
243
+ const gained = Number(detail.chars) > prevChars || Number(detail.toolCalls) > prevTools || Number(detail.reasoningChars) > prevReason;
244
+ // 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
245
+ //(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
246
+ // 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
247
+ if (isPayload && !holdErr && (timedOut || stalled)) {
248
+ timedOut = false;
249
+ stalled = false;
250
+ detail.recoveries = (detail.recoveries || 0) + 1;
251
+ if (detail.exitReason === "first-timeout" || detail.exitReason === "stall") detail.exitReason = null;
252
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
253
+ }
254
+ if (timedOut || stalled || tooLong) break;
255
+ if (isPayload && first && !holdErr) {
256
+ first = false;
257
+ ttf = Math.round(now - t0);
258
+ onFirstChunk?.(ttf);
259
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
260
+ }
261
+ let outChunk = chunk;
262
+ if (first === false && fallback?.fallback && wrotePayload === false) {
263
+ try {
264
+ let txt = "";
265
+ if (Buffer.isBuffer(chunk)) txt = chunk.toString("utf8");
266
+ else if (chunk instanceof Uint8Array) txt = Buffer.from(chunk).toString("utf8");
267
+ else if (typeof chunk === "string") txt = chunk;
268
+ if (txt.includes("data:")) {
269
+ const enriched = enrichSseChunkText(txt, fallback);
270
+ if (enriched !== txt) outChunk = Buffer.from(enriched, "utf8");
271
+ }
272
+ } catch {}
273
+ }
274
+ if (!holdErr) {
275
+ const size = Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
276
+ if (!isPayload) {
277
+ // 注释帧等非数据帧:与改前一致直写(客户端视作噪声,不影响可撤销性)
278
+ wroteAny = true;
279
+ prefix.writeNow(outChunk, size);
280
+ } else if (gained || !revocableHold || !isTrivialFrame(txt)) {
281
+ // 有模型产出(正文/工具/思考)、留口已关、或看不懂的异形帧:按序补写暂扣前缀 + 本帧,
282
+ // 这条流就此提交不可撤销(透传契约要求解析失败的帧必须原样走,绝不为救空轮扣下)
283
+ wroteAny = true;
284
+ wrotePayload = true; detail.wrotePayload = true;
285
+ prefix.submit(outChunk, size);
286
+ } else if (!prefix.hold(outChunk, size, txt.includes("[DONE]"))) {
287
+ // 可撤销前缀:空 delta 暂扣(判为空轮时整段撤销,留给重拉的正文);
288
+ // [DONE] 无论是否已提交都进 tail 槽 —— 错误帧必须能排在它前面,否则客户端一见 [DONE] 就收尾
289
+ wroteAny = true;
290
+ wrotePayload = true; detail.wrotePayload = true;
291
+ prefix.submit(outChunk, size); // 暂扣超上限(异常形状)→ 被迫提交,退回旧行为
292
+ }
293
+ }
294
+ armStall();
295
+ }
296
+ if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
297
+ } catch (err) {
298
+ detail.upstreamError = String(err?.message || err).slice(0, 300);
299
+ detail.exitReason = "upstream-error";
300
+ if (!wrotePayload) timedOut = true;
301
+ else stalled = true;
302
+ } finally {
303
+ if (firstTimer) clearTimeout(firstTimer);
304
+ if (maxTimer) clearTimeout(maxTimer);
305
+ if (stallTimer) clearTimeout(stallTimer);
306
+ if (pingTimer) clearInterval(pingTimer);
307
+ }
308
+ // 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
309
+ if ((timedOut || stalled || tooLong) && !wrotePayload) {
310
+ res.removeListener("close", onClose);
311
+ // Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
312
+ return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, preflightMs: preflight, detail };
313
+ }
314
+ if ((stalled || tooLong) && wrotePayload) {
315
+ interrupted = true;
316
+ detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
317
+ flushHeldTail(); // 断流收场也要把 [DONE] 按序补上,别让客户端等一个不存在的终止符
318
+ try { res.end(); } catch { /* ignore */ }
319
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, preflightMs: preflight, detail };
320
+ }
321
+ } else {
322
+ detail.exitReason = "empty-body";
323
+ }
324
+ const totalMs = Math.round(performance.now() - t0);
325
+ if (!detail.exitReason) detail.exitReason = "normal";
326
+ finishedNormally = true;
327
+ // 留口期间不摘 close 监听(close 只发一次,摘了客户端窗口内断开就永久盲窗、照烧额度)
328
+ const heldOpen = holdEndEnabled() && detail.wrotePayload !== true && !detail.downstreamClosed && isEmptyTurnDetail(detail);
329
+ if (!heldOpen) res.removeListener("close", onClose);
330
+ let terminalForm = null;
331
+ if (heldOpen) {
332
+ detail.exitReason = "empty-turn-hold";
333
+ } else {
334
+ if (!detail.downstreamClosed) {
335
+ commitPendingPrefix(); // 不留口 = 补写暂扣前缀(HOLD_END=0 时逐字节复现改前行为)
336
+ // 思考刷满 max_tokens、正文为零(现网主流):思考撤不回,错误帧必须排在 [DONE] 之前才看得见
337
+ const reasoningOnly = Number(detail.chars) === 0 && Number(detail.toolCalls) === 0 && Number(detail.reasoningChars) > 0;
338
+ if (reasoningOnly && holdEndEnabled()) {
339
+ detail.exitReason = "reasoning-only";
340
+ terminalForm = "sse-error-tail";
341
+ endEmptyTurnStream(res, `EMPTY_MODEL_RESPONSE: 只产出思考 ${detail.reasoningChars} 字、正文为零(额度被思考吃满),已重试仍无正文 — raise max_tokens or rephrase`, () => flushHeldTail());
342
+ } else {
343
+ flushHeldTail(); // 没有错误帧要插队,[DONE] 按原序发出
344
+ try { res.end(); } catch { /* ignore */ }
345
+ }
346
+ } else {
347
+ try { res.end(); } catch { /* ignore */ }
348
+ }
349
+ }
350
+ return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, heldOpen, terminalForm, preflightMs: preflight, detail };
351
+ }
352
+
353
+ finishedNormally = true;
354
+ res.removeListener("close", onClose);
355
+ const text = await upRes.text();
356
+ detail.receivedBytes = Buffer.byteLength(text);
357
+ detail.exitReason = "normal-non-stream";
358
+ // 非流式同样「先判后写」:此刻 headers 还没 flush,body 一写下去就再无退路。
359
+ let enriched = null;
360
+ try {
361
+ const parsed = JSON.parse(text);
362
+ scanNonStreamBody(detail, parsed);
363
+ enriched = enrichNonStreamJson(parsed, fallback);
364
+ } catch { /* 上游给的不是可解析 JSON:走下面的原文透传 */ }
365
+ if (enriched === null) {
366
+ // 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
367
+ try { detail.chars = text.length; } catch {}
368
+ try { const u = extractUsageFromSseText(text); if (u) detail.usage = u; } catch {}
369
+ try { if (detail.talk) captureTalkFallback(detail.talk, text); } catch {}
370
+ } else if (holdEndEnabled() && !detail.downstreamClosed && isEmptyTurnDetail(detail)) {
371
+ detail.exitReason = "empty-turn-hold";
372
+ return { status: upRes.status, timedOut: false, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted: false, heldOpen: true, preflightMs: preflight, detail };
373
+ }
374
+ detail.wrotePayload = true; // 真写下去了:从此撤不回,也不再判空轮
375
+ const nsTotalMs = Math.round(performance.now() - t0);
376
+ if (enriched !== null) {
377
+ json(res, upRes.status, enriched);
378
+ } else {
379
+ res.statusCode = upRes.status;
380
+ res.setHeader("Content-Type", contentType || "text/plain");
381
+ res.end(text);
382
+ }
383
+ // 非流式 ttf 视为 total(一次性返回)
384
+ return { status: upRes.status, timedOut: false, ttfMs: nsTotalMs, totalMs: nsTotalMs, aborted: false, interrupted: false, heldOpen: false, preflightMs: preflight, detail };
385
+ }