mslxdff 0.1.199 → 0.1.203

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 (327) hide show
  1. package/README.md +3 -3
  2. package/bin/mslxdff.js +1 -1
  3. package/package.json +38 -38
  4. package/plugins/README.md +14 -14
  5. package/plugins/prefer-model.mjs.example +14 -14
  6. package/src/anthropic/count-tokens.js +61 -61
  7. package/src/anthropic/request-body.js +126 -126
  8. package/src/anthropic/shared.js +18 -18
  9. package/src/anthropic/sse-aggregate.js +56 -56
  10. package/src/anthropic/translate.js +287 -287
  11. package/src/anthropic/web-search.js +361 -361
  12. package/src/api-kind.js +31 -31
  13. package/src/autostart.js +288 -288
  14. package/src/bench/probe.js +1 -1
  15. package/src/bench/runner.js +1 -1
  16. package/src/bench/via-probe.js +1 -1
  17. package/src/bench/via.js +144 -144
  18. package/src/bench/workbuddy-bench.js +1 -1
  19. package/src/chat/config.js +11 -11
  20. package/src/chat/cooling.js +81 -81
  21. package/src/chat/direct.js +58 -58
  22. package/src/chat/index.js +6 -6
  23. package/src/chat/spinner.js +38 -38
  24. package/src/chat/sse.js +69 -69
  25. package/src/chat/stats.js +247 -247
  26. package/src/chat/store.js +48 -48
  27. package/src/chat/tool-handlers.js +67 -67
  28. package/src/chat/tools.js +1 -1
  29. package/src/chat/upstream.js +59 -59
  30. package/src/chat-pipeline/auto-race.js +158 -158
  31. package/src/chat-pipeline/empty-turn.js +147 -147
  32. package/src/chat-pipeline/engine.js +17 -17
  33. package/src/chat-pipeline/serial-trial.js +283 -283
  34. package/src/chat-pipeline/terminal-verdict.js +147 -147
  35. package/src/chooser.js +54 -54
  36. package/src/claude-desktop/apply.js +101 -101
  37. package/src/claude-desktop/context1m.js +38 -38
  38. package/src/claude-desktop/doctor.js +146 -146
  39. package/src/claude-desktop/slots.js +152 -152
  40. package/src/cli/bootstrap.js +1 -1
  41. package/src/cli/commands/claude-desktop.js +284 -284
  42. package/src/cli/commands/daemon.js +231 -231
  43. package/src/cli/commands/model/list-live.js +22 -22
  44. package/src/cli/commands/model/list-providers.js +76 -76
  45. package/src/cli/commands/model/list-render.js +77 -77
  46. package/src/cli/commands/model/list-sort.js +52 -52
  47. package/src/cli/commands/model/list-tty.js +76 -76
  48. package/src/cli/commands/model/list.js +166 -166
  49. package/src/cli/commands/model/live-models.js +24 -24
  50. package/src/cli/commands/model/stats.js +43 -43
  51. package/src/cli/commands/model/status.js +47 -47
  52. package/src/cli/commands/model.js +51 -51
  53. package/src/cli/commands/provider/add.js +108 -108
  54. package/src/cli/commands/provider/allowlist.js +99 -99
  55. package/src/cli/commands/provider/cline-free.js +99 -99
  56. package/src/cli/commands/provider/cline-login.js +218 -218
  57. package/src/cli/commands/provider/cline-quota.js +65 -65
  58. package/src/cli/commands/provider/codearts-login.js +45 -45
  59. package/src/cli/commands/provider/config.js +119 -119
  60. package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
  61. package/src/cli/commands/provider/keys-guard.js +50 -50
  62. package/src/cli/commands/provider/keys.js +161 -161
  63. package/src/cli/commands/provider/qoder-checkin.js +81 -81
  64. package/src/cli/commands/provider/qoder-login.js +102 -102
  65. package/src/cli/commands/provider/raccoon-login.js +180 -180
  66. package/src/cli/commands/provider/raccoon-password.js +162 -162
  67. package/src/cli/commands/provider/raccoon-quota.js +109 -109
  68. package/src/cli/commands/provider/traework-checkin.js +144 -144
  69. package/src/cli/commands/provider/traework-login.js +164 -164
  70. package/src/cli/commands/provider/workbuddy-login.js +153 -153
  71. package/src/cli/commands/provider/zcode-login.js +77 -77
  72. package/src/cli/commands/provider/zcode-quota.js +55 -55
  73. package/src/cli/commands/provider.js +1 -1
  74. package/src/cli/commands/stats.js +149 -149
  75. package/src/cli/commands/system.js +261 -261
  76. package/src/cli/commands/timezone.js +47 -47
  77. package/src/cli/commands/use-group.js +60 -60
  78. package/src/cli/commands/workbuddy.js +189 -189
  79. package/src/cli/format.js +141 -141
  80. package/src/cli/group-helpers.js +70 -70
  81. package/src/cli/help.js +80 -80
  82. package/src/cli/index.js +70 -70
  83. package/src/cli/interactive.js +121 -121
  84. package/src/cli/join-core.js +99 -99
  85. package/src/cli/join-wizard.js +175 -175
  86. package/src/cli/policy.js +136 -136
  87. package/src/cli/provider-row.js +126 -126
  88. package/src/cli/util.js +24 -24
  89. package/src/compat.js +53 -53
  90. package/src/events.js +38 -38
  91. package/src/free-lane.js +131 -131
  92. package/src/free-watcher.js +63 -63
  93. package/src/groups.js +262 -262
  94. package/src/logs.js +195 -195
  95. package/src/metrics.js +62 -62
  96. package/src/model-capabilities/context-lookup.js +65 -65
  97. package/src/model-capabilities/merge.js +142 -142
  98. package/src/model-trace.js +187 -187
  99. package/src/models.js +1 -1
  100. package/src/opencode-identity.js +72 -72
  101. package/src/peers.js +254 -251
  102. package/src/plugins.js +107 -107
  103. package/src/providers/base.js +164 -164
  104. package/src/providers/classify.js +22 -22
  105. package/src/providers/cline/allowlist-sync.js +93 -93
  106. package/src/providers/cline/free-catalog.js +71 -71
  107. package/src/providers/cline/headers.js +37 -37
  108. package/src/providers/cline/index.js +130 -130
  109. package/src/providers/cline/models.js +150 -150
  110. package/src/providers/cline/usage.js +274 -274
  111. package/src/providers/codearts/auth-pool.js +148 -148
  112. package/src/providers/codearts/concurrency.js +34 -34
  113. package/src/providers/codearts/const.js +65 -65
  114. package/src/providers/codearts/dpop.js +95 -95
  115. package/src/providers/codearts/index.js +175 -175
  116. package/src/providers/codearts/login.js +214 -214
  117. package/src/providers/codearts/models.js +182 -182
  118. package/src/providers/codearts/sign.js +96 -96
  119. package/src/providers/codearts/sse.js +204 -204
  120. package/src/providers/codearts/stream.js +209 -209
  121. package/src/providers/codearts/sts.js +124 -124
  122. package/src/providers/codearts.js +1 -1
  123. package/src/providers/dispatcher.js +146 -146
  124. package/src/providers/generic.js +156 -156
  125. package/src/providers/globalqwenwork/account-store.js +141 -141
  126. package/src/providers/globalqwenwork/constants.js +73 -73
  127. package/src/providers/globalqwenwork/crypto.js +2 -2
  128. package/src/providers/globalqwenwork/http.js +2 -2
  129. package/src/providers/globalqwenwork/index.js +331 -331
  130. package/src/providers/globalqwenwork/payload.js +147 -147
  131. package/src/providers/globalqwenwork/rsa.js +2 -2
  132. package/src/providers/globalqwenwork/sse.js +2 -2
  133. package/src/providers/globalqwenwork/stream.js +2 -2
  134. package/src/providers/globalqwenwork.js +1 -1
  135. package/src/providers/globalworkbuddy/account-store.js +198 -198
  136. package/src/providers/globalworkbuddy/auth.js +97 -97
  137. package/src/providers/globalworkbuddy/catalog.js +138 -138
  138. package/src/providers/globalworkbuddy/constants.js +73 -73
  139. package/src/providers/globalworkbuddy/device-auth.js +120 -120
  140. package/src/providers/globalworkbuddy/errors.js +98 -98
  141. package/src/providers/globalworkbuddy/headers.js +89 -89
  142. package/src/providers/globalworkbuddy/models.js +96 -96
  143. package/src/providers/globalworkbuddy/payload.js +80 -80
  144. package/src/providers/index.js +5 -5
  145. package/src/providers/model-id.js +172 -172
  146. package/src/providers/opencode.js +19 -19
  147. package/src/providers/openrouter.js +31 -31
  148. package/src/providers/qoder/account-store.js +106 -106
  149. package/src/providers/qoder/aggregate.js +60 -60
  150. package/src/providers/qoder/baseprompt.json +591 -591
  151. package/src/providers/qoder/checkin.js +160 -160
  152. package/src/providers/qoder/constants.js +44 -44
  153. package/src/providers/qoder/encode.js +41 -41
  154. package/src/providers/qoder/fingerprint.js +38 -38
  155. package/src/providers/qoder/models.js +77 -77
  156. package/src/providers/qoder/oauth.js +84 -84
  157. package/src/providers/qoder/payload.js +162 -162
  158. package/src/providers/qoder/request.js +27 -27
  159. package/src/providers/qoder/session.js +134 -134
  160. package/src/providers/qoder/sticky.js +38 -38
  161. package/src/providers/qoder/stream.js +116 -116
  162. package/src/providers/qoder.js +1 -1
  163. package/src/providers/raccoon/account-store.js +216 -216
  164. package/src/providers/raccoon/auth.js +67 -67
  165. package/src/providers/raccoon/chat.js +228 -228
  166. package/src/providers/raccoon/const.js +180 -165
  167. package/src/providers/raccoon/credits.js +185 -175
  168. package/src/providers/raccoon/crypto.js +43 -0
  169. package/src/providers/raccoon/envelope.js +31 -31
  170. package/src/providers/raccoon/headers.js +45 -45
  171. package/src/providers/raccoon/index.js +216 -216
  172. package/src/providers/raccoon/login.js +279 -279
  173. package/src/providers/raccoon/models.js +160 -160
  174. package/src/providers/raccoon/password-login.js +102 -87
  175. package/src/providers/raccoon/qr-codewords.js +115 -115
  176. package/src/providers/raccoon/qr.js +236 -236
  177. package/src/providers/raccoon/sse.js +135 -135
  178. package/src/providers/responses-channel.js +120 -120
  179. package/src/providers/share-keys.js +53 -53
  180. package/src/providers/traework/account-store.js +234 -234
  181. package/src/providers/traework/chat.js +217 -217
  182. package/src/providers/traework/checkin.js +77 -77
  183. package/src/providers/traework/constants.js +41 -41
  184. package/src/providers/traework/errors.js +52 -52
  185. package/src/providers/traework/headers.js +79 -79
  186. package/src/providers/traework/index.js +132 -132
  187. package/src/providers/traework/market-identity.js +116 -116
  188. package/src/providers/traework/models.js +98 -98
  189. package/src/providers/traework/payload.js +86 -86
  190. package/src/providers/traework/sse.js +184 -184
  191. package/src/providers/traework/token.js +52 -52
  192. package/src/providers/traework.js +2 -2
  193. package/src/providers/workbuddy/account-store.js +186 -186
  194. package/src/providers/workbuddy/auth.js +158 -158
  195. package/src/providers/workbuddy/balance.js +86 -86
  196. package/src/providers/workbuddy/cat-travel.js +125 -125
  197. package/src/providers/workbuddy/checkin.js +75 -75
  198. package/src/providers/workbuddy/desktop-info.js +78 -78
  199. package/src/providers/workbuddy/growth-api.js +67 -67
  200. package/src/providers/workbuddy/growth-plans.js +63 -63
  201. package/src/providers/workbuddy/growth.js +181 -181
  202. package/src/providers/workbuddy/index.js +217 -217
  203. package/src/providers/workbuddy/models.js +126 -126
  204. package/src/providers/workbuddy/payload.js +141 -141
  205. package/src/providers/workbuddy/rotation-log.js +83 -83
  206. package/src/providers/workbuddy/sanitize-tools.js +61 -61
  207. package/src/providers/workbuddy/sdk-chat.js +26 -26
  208. package/src/providers/workbuddy-balance.js +78 -78
  209. package/src/providers/zcode/account-store.js +129 -129
  210. package/src/providers/zcode/auth.js +28 -28
  211. package/src/providers/zcode/chat.js +179 -179
  212. package/src/providers/zcode/const.js +54 -54
  213. package/src/providers/zcode/context-shape.js +162 -162
  214. package/src/providers/zcode/headers.js +93 -93
  215. package/src/providers/zcode/index.js +124 -124
  216. package/src/providers/zcode/models.js +65 -65
  217. package/src/providers/zcode/oauth.js +120 -120
  218. package/src/providers/zcode/quota.js +176 -176
  219. package/src/providers/zcode/sse.js +196 -196
  220. package/src/readline-compat.js +25 -25
  221. package/src/responses/translate.js +311 -311
  222. package/src/routes/chat/broadband-handler.js +72 -72
  223. package/src/routes/chat/empty-turn-report.js +31 -31
  224. package/src/routes/chat/exhausted-handler.js +87 -87
  225. package/src/routes/chat/hedge-handler.js +130 -130
  226. package/src/routes/chat/index.js +1 -1
  227. package/src/routes/chat/local-handler.js +63 -63
  228. package/src/routes/chat/peer-handler.js +81 -81
  229. package/src/routes/chat.js +3 -3
  230. package/src/routes/fallback.js +88 -88
  231. package/src/routes/groups-relay.js +214 -214
  232. package/src/routes/groups.js +122 -122
  233. package/src/routes/helpers.js +83 -83
  234. package/src/routes/index.js +147 -147
  235. package/src/routes/messages-route.js +365 -365
  236. package/src/routes/peers.js +23 -1
  237. package/src/routes/relay-queue.js +181 -181
  238. package/src/routes/relay.js +60 -60
  239. package/src/routes/responses-route.js +213 -213
  240. package/src/routes/stream-body.js +84 -84
  241. package/src/routes/stream-hold.js +337 -337
  242. package/src/routes/stream-scan.js +366 -366
  243. package/src/routes.js +3 -3
  244. package/src/runtime/auto-update.js +113 -113
  245. package/src/runtime/bootstrap.js +8 -0
  246. package/src/runtime/broadband-stream.js +79 -79
  247. package/src/runtime/broadband.js +103 -103
  248. package/src/runtime/group-sync.js +81 -81
  249. package/src/runtime/keep-warm.js +120 -0
  250. package/src/runtime/lifecycle-forensics.js +116 -116
  251. package/src/runtime/lifecycle-log.js +87 -87
  252. package/src/runtime/log-stamp.js +73 -73
  253. package/src/runtime/proc-info.js +137 -137
  254. package/src/runtime/qoder-checkin.js +93 -93
  255. package/src/runtime/raccoon-checkin.js +126 -126
  256. package/src/runtime/server-lifecycle.js +189 -189
  257. package/src/runtime/traework-checkin.js +118 -118
  258. package/src/runtime/workbuddy-checkin.js +120 -120
  259. package/src/runtime/workbuddy-growth.js +133 -133
  260. package/src/server.js +7 -0
  261. package/src/state/facade.js +58 -58
  262. package/src/state/memory.js +162 -162
  263. package/src/state/merge.js +25 -25
  264. package/src/state/migrations/cline-unify.js +94 -94
  265. package/src/state/migrations.js +33 -33
  266. package/src/state/persist.js +42 -42
  267. package/src/state/provider-config.js +142 -142
  268. package/src/state/schemas/group.js +31 -31
  269. package/src/state/schemas/model.js +94 -94
  270. package/src/state/schemas/peer.js +31 -31
  271. package/src/state/schemas/port.js +10 -10
  272. package/src/state/schemas/provider.js +202 -202
  273. package/src/state/schemas/timezone.js +70 -70
  274. package/src/state/schemas/token.js +48 -48
  275. package/src/state/schemas/use-group.js +102 -102
  276. package/src/state/store.js +176 -176
  277. package/src/state.js +1 -1
  278. package/src/sync-claude-desktop.js +234 -234
  279. package/src/sync-claude.js +152 -152
  280. package/src/sync-codex.js +116 -116
  281. package/src/sync-workbuddy.js +158 -158
  282. package/src/talk-full-redact.js +129 -129
  283. package/src/talk-full.js +131 -131
  284. package/src/talk-log.js +257 -257
  285. package/src/text-tokens.js +52 -52
  286. package/src/thinking-effort.js +67 -67
  287. package/src/time.js +106 -106
  288. package/src/timeline.js +26 -26
  289. package/src/transport/index.js +1 -1
  290. package/src/upstream-engine/index.js +74 -72
  291. package/src/upstream-engine/mode.js +15 -15
  292. package/src/upstream-engine/sdk/attempt.js +221 -221
  293. package/src/upstream-engine/sdk/chat.js +28 -28
  294. package/src/upstream-engine/sdk/convert.js +192 -192
  295. package/src/upstream-engine/sdk/diagnose.js +62 -62
  296. package/src/upstream-engine/sdk/dispatch.js +55 -55
  297. package/src/upstream-engine/sdk/responses.js +176 -176
  298. package/src/upstream-engine/sdk/sse.js +166 -166
  299. package/src/upstream-responses.js +305 -305
  300. package/src/upstream.js +53 -1
  301. package/src/usage/record.js +106 -106
  302. package/src/usage/report.js +195 -195
  303. package/src/v6pool/claim.js +157 -157
  304. package/src/v6pool/config.js +65 -65
  305. package/src/v6pool/detect.js +130 -130
  306. package/src/v6pool/index.js +152 -152
  307. package/src/v6pool/prefix.js +140 -140
  308. package/src/v6pool/probe.js +40 -40
  309. package/src/v6pool/report.js +104 -104
  310. package/src/v6pool/rotate.js +263 -239
  311. package/src/warp-pool/config.js +74 -74
  312. package/src/warp-pool/health.js +106 -106
  313. package/src/warp-pool/identity.js +141 -141
  314. package/src/warp-pool/index.js +358 -358
  315. package/src/warp-pool/keys.js +29 -29
  316. package/src/warp-pool/nodes.js +95 -95
  317. package/src/warp-pool/ports.js +160 -160
  318. package/src/warp-pool/register.js +113 -113
  319. package/src/warp-pool/report.js +232 -232
  320. package/src/warp-pool/revive.js +168 -168
  321. package/src/warp-pool/rotate.js +214 -214
  322. package/src/warp-pool/strategy.js +43 -43
  323. package/src/warp-pool/verdict.js +69 -69
  324. package/src/warp-pool/wireproxy.js +162 -162
  325. package/docs/cli_help_mini.md +0 -146
  326. package/src/providers/AGENTS.md +0 -49
  327. package/src/routes/AGENTS.md +0 -37
@@ -1,337 +1,337 @@
1
- // 空轮的可挽救性策略(relay 的三个问题在这里回答,stream.js 只负责转发循环):
2
- // ① 哪些帧还能撤销?—— 一次尝试在「出现任何模型产出」之前写下去的只有空 delta 帧与 [DONE],
3
- // 这些字节一旦入下游就撤不回(重拉的正文会接在一个已完成的流后面),所以一律暂扣;
4
- // 攒爆上限(异常形状)才被迫提交,行为退回改前。
5
- // ② 撤销窗口留不留?—— 流结束仍零产出且下游还活着 → 不 res.end(),把这次尝试交回同模型重拉 /
6
- // 换候选,让后面那发的正文还能顺着同一条连接送出去。
7
- // ③ 全部救不回来怎么收场?—— 恰好一次、形状与已发出的字节相容(见 endHeldFailure / endEmptyTurnStream)。
8
- // 另有一条次序铁律:`[DONE]` 是流的终点,客户端解析器一见它就收尾 —— 所以**任何要补在它前面的错误帧
9
- // 都必须先把 [DONE] 暂扣住**(tail 槽),否则错误永不可见、还会留下两个终止符。
10
- // 拆出原因:这些策略与 relay 本体同处一文件会破仓库 20KB 硬门,且语义本就独立(不碰上游读取与闸门计时)。
11
- import { json } from "./helpers.js";
12
- import { isTrivialFrame } from "./stream-scan.js";
13
-
14
- // 暂扣上限:正常空轮的前缀就是几帧空 delta + 一个 [DONE];超过即说明不是这个形状,别为它囤内存。
15
- export const HOLD_MAX_FRAMES = 64;
16
- export const HOLD_MAX_BYTES = 32 * 1024;
17
-
18
- /**
19
- * 把一个 chunk 里夹带的 `[DONE]` 终止帧切下来:返回 { head, rest },head = 该帧之前的全部字节,
20
- * rest = 从 `data: [DONE]` 那一行的行首到 chunk 末尾。
21
- *
22
- * 为什么必须在 submit 之前切:网络 chunk 边界与 SSE 帧边界**无关**(TCP 分段事实),上游常把末个
23
- * 内容帧与 `data: [DONE]` 一次 flush 送进同一个 chunk。这种帧若整体直写,`[DONE]` 就抢在错误帧
24
- * 之前到达客户端 —— 客户端一见 `[DONE]` 即收尾,截断错误帧永远看不见(BUG-17 的修复在这类形态下
25
- * 完全失效),且收场还会因为 tail 槽空着而补出第二个 `[DONE]`。故 `[DONE]` 必须扣进 tail 槽,
26
- * 由 flushTail 决定它的最终时机(错误帧之后)。
27
- *
28
- * 必须**行锚定**:正文里出现字面量 `[DONE]`(本仓域内很常见——讨论 SSE 的对话)绝不能误判成终止帧,
29
- * 故只认「整行 trims 后等于 `data: [DONE]`」。扫描走 latin1(1 字节↔1 字符),索引与字节一一对应,
30
- * 不受多字节字符切分影响。
31
- */
32
- function toBuf(buf) {
33
- if (typeof buf === "string") return Buffer.from(buf, "utf8");
34
- if (Buffer.isBuffer(buf)) return buf;
35
- if (buf instanceof Uint8Array) return Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
36
- return null;
37
- }
38
- const DONE_LINE_RE = /(^|\n)[ \t]*data:[ \t]*\[DONE\][ \t]*\r?(?=\n|$)/g;
39
- function splitTrailingDone(buf) {
40
- const b = toBuf(buf);
41
- if (!b) return null;
42
- const s = b.toString("latin1");
43
- DONE_LINE_RE.lastIndex = 0;
44
- let last = null;
45
- for (let m; (m = DONE_LINE_RE.exec(s)); ) last = m; // 取最后一个终止帧行
46
- if (!last) return null;
47
- const head = last.index + (last[1] === "\n" ? 1 : 0);
48
- return { head: b.subarray(0, head), rest: b.subarray(head) };
49
- }
50
-
51
- /**
52
- * 这个 chunk 的**最后一个未结束行**像不像 `data: [DONE]` 的半截?
53
- *
54
- * 上面的行锚定只解决「完整终止帧与正文同 chunk」,解决不了**终止帧自己被 TCP 切开**
55
- * (`…data: [DO` | `NE]\n\n` 两个 chunk)。这种形态下行锚定永远匹配不到 ⇒ 终止符照样
56
- * 抢先于错误帧、收场照样补出第二个 `[DONE]`(实测复现)。故凡尾部是终止行前缀的,
57
- * 先扣住等下一块拼上再判;下一块到齐后发现不是终止帧,立刻原样放行。
58
- *
59
- * 只认「去掉行首空白后是 `data: [DONE]` 的字面前缀」,且长度有上限(半个帧不会很长),
60
- * 避免把普通正文尾巴扣住。
61
- */
62
- const MAX_DONE_LINE_LEN = 32;
63
- function doneLinePrefix(buf) {
64
- const s = buf.toString("latin1");
65
- const nl = s.lastIndexOf("\n");
66
- const tail = s.slice(nl + 1); // 最后一个 \n 之后 = 本 chunk 未结束的那一行
67
- if (!tail || tail.length > MAX_DONE_LINE_LEN) return 0;
68
- const t = tail.replace(/^[ \t]+/, "").toLowerCase().replace(/[ \t]+/g, " ");
69
- if (!t) return 0;
70
- // `data: [DONE]` 与 `data:[DONE]` 两种空白写法都可能是上游形式
71
- if ("data: [done]".startsWith(t) || "data:[done]".startsWith(t)) return Buffer.byteLength(tail, "latin1");
72
- return 0;
73
- }
74
-
75
- // 空轮「延后封口 + 前缀暂扣」总开关(默认开)。0/off/false = 关:连缓冲都不做,流一结束立即 end(),
76
- // 逐字节复现改前行为(代价:之后的空轮重拉/换候选写的是一次已终结的响应,只省额度、救不回正文)。
77
- export function holdEndEnabled() {
78
- const s = String(process.env.MSLXDFF_EMPTY_TURN_HOLD_END ?? "").trim().toLowerCase();
79
- return !(s === "0" || s === "off" || s === "false");
80
- }
81
-
82
- /**
83
- * 可撤销前缀缓冲。帧分三类:
84
- * - 注释帧(非数据帧):直写,不影响可撤销性(客户端视作噪声);
85
- * - 可撤销前缀(空 delta / finish-only 帧):暂扣,出现模型产出时按序补写;
86
- * - `[DONE]`:即使已提交也暂扣在 tail 槽,直到确认没有错误帧要补在它前面。
87
- * 记账(detail.wroteChunks/wroteBytes)由本模块负责,调用方不必自己数字节。
88
- */
89
- export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES, maxBytes = HOLD_MAX_BYTES } = {}) {
90
- let pending = [];
91
- let pendingBytes = 0;
92
- let tail = null; // [DONE] 槽:必须最后写,且要让位给错误帧
93
- let carry = null; // 半截 `data: [DONE]` 行:等下一块拼上再判(见 doneLinePrefix)
94
- let committed = false;
95
- let draining = false;
96
- let inCarryFlush = false;
97
- const writeNow = (buf, size) => {
98
- // carry 是**先到**的字节:任何直写都必须等它先出,否则下游看到乱序帧。
99
- // 之前只有 submit() 自己消化 carry,hold() 的两个分支与注释帧直写都绕过了它(实测确认)。
100
- if (!inCarryFlush && carry) flushCarry();
101
- if (!draining && tail) flushTail(); // 保持帧序:先补已暂扣的 [DONE],再写新到的
102
- detail.wroteChunks += 1;
103
- detail.wroteBytes += size;
104
- try { res.write(buf); } catch { /* 下游已断开:onClose 已掐上游 */ }
105
- };
106
- /**
107
- * 放行扣着的半截行。它若没以换行结尾**必须补一个** —— 否则随后的错误帧 / `[DONE]` 会粘在同一行上
108
- * (`data: [DOdata: [DONE]`):既不是合法 SSE 行、客户端也认不出终止符(实测终止符数=0,比多一个更糟)。
109
- */
110
- const flushCarry = () => {
111
- if (!carry) return false;
112
- const c = carry;
113
- carry = null;
114
- const endsWithNl = c.size > 0 && c.buf[c.size - 1] === 0x0a;
115
- const b = endsWithNl
116
- ? (toBuf(c.buf) ?? Buffer.alloc(0))
117
- : Buffer.concat([toBuf(c.buf) ?? Buffer.alloc(0), Buffer.from("\n", "utf8")]);
118
- inCarryFlush = true;
119
- try { writeNow(b, b.length); } finally { inCarryFlush = false; }
120
- return true;
121
- };
122
- const drainPending = () => {
123
- draining = true;
124
- try {
125
- for (const p of pending) writeNow(p.buf, p.size);
126
- } finally {
127
- draining = false;
128
- }
129
- pending = [];
130
- pendingBytes = 0;
131
- };
132
- const flushTail = () => {
133
- if (!tail) return false;
134
- const t = tail;
135
- tail = null;
136
- detail.wroteChunks += 1;
137
- detail.wroteBytes += t.size;
138
- try { res.write(t.buf); } catch { /* 下游已断开 */ }
139
- return true; // 告诉收场方:[DONE] 已经由这一帧发出去了,别再补第二个
140
- };
141
- /**
142
- * 暂扣一帧。终止帧进 tail 槽(无论是否已提交);否则进 pending(未提交时可整段撤销)。
143
- * 返回 false = pending 超上限放不下,调用方必须改为提交,别默默丢字节。
144
- *
145
- * **必须自己拼 carry**:上一块的半截终止行扣在 carry 里,本块就是它的续块。若让调用方分别处理,
146
- * 两块会走各自的路径(carry 被收场 `flushCarry` 补一个换行 → 终止帧被拆成两行、客户端认不出终止符),
147
- * 更糟的是**本块会因「不含 data:」被判成非空帧而被迫提交**,直接摧毁空轮留口(BUG-40 实测:
148
- * 空轮 + `[DONE]` 被 TCP 切两块时 heldOpen 由 true 变 false、wrotePayload 由 false 变 true,
149
- * 救援静默失效)。拼接是字节流的本分:carry 之后紧跟的必然是该行的续字节。
150
- */
151
- function holdFrame(buf, size) {
152
- let b = buf;
153
- if (carry) {
154
- // 只有「本块恰好补完那个终止行」才该拼;否则 carry 里是上一块**残缺的一行**,
155
- // 必须按序先放出去(writeNow 会补换行),硬拼会把它与本帧粘成 `data: [DOdata: {...` ——
156
- // 非法行,客户端认不出终止符(实测终止符数=0)。已提交时更是如此:此时本帧要直写。
157
- const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), toBuf(buf) ?? Buffer.alloc(0)]);
158
- if (committed && !splitTrailingDone(joined) && doneLinePrefix(joined) === 0) {
159
- writeNow(buf, size); // writeNow 内部先 flushCarry(带换行),帧序与行完整性都保住
160
- return true;
161
- }
162
- b = joined;
163
- carry = null;
164
- size = b.length;
165
- }
166
- // 本块夹带终止帧:头段照常走本函数,终止帧扣进 tail(行锚定,正文里的字面量不算)
167
- const sp = splitTrailingDone(b);
168
- if (sp) {
169
- if (sp.head.length && !holdFrame(sp.head, sp.head.length)) return false;
170
- if (tail) flushTail();
171
- tail = { buf: sp.rest, size: sp.rest.length };
172
- return true;
173
- }
174
- // 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块拼上再判
175
- const pl = doneLinePrefix(b);
176
- if (pl > 0) {
177
- const head = b.subarray(0, b.length - pl);
178
- if (head.length && !holdFrame(head, head.length)) return false;
179
- const keep = pl < b.length ? b.subarray(b.length - pl) : b;
180
- carry = { buf: keep, size: keep.length };
181
- return true;
182
- }
183
- if (committed) { writeNow(b, size); return true; }
184
- if (pending.length >= maxFrames || pendingBytes + size > maxBytes) return false;
185
- pending.push({ buf: b, size });
186
- pendingBytes += size;
187
- return true;
188
- }
189
- return {
190
- get committed() { return committed; },
191
- get heldFrames() { return pending.length + (tail ? 1 : 0); },
192
- hold: holdFrame,
193
- /**
194
- * 本块是否仍属「可撤销前缀」——必须连同 carry 一起判,且**半截终止行本身也算**。
195
- * 只看本块会漏两种:① 续块(`NE]\n\n`)自身不含 `data:`,`isTrivialFrame` 必判 false;
196
- * ② 首块(`data: [DO`)自身是半个终止帧,JSON 解析失败也判 false。两者都会让调用方走 submit
197
- * 提交、留口失效(BUG-40)。凡尾部是终止行前缀的一律按可暂扣处理:它会进 carry 等下一块。
198
- */
199
- isTrivial(txt) {
200
- const t = Buffer.from(String(txt ?? ""), "utf8");
201
- // 尾部是终止行前缀(`data: [DO`):它是**残缺的一行**,会被扣进 carry 等续块,本身不含模型产出。
202
- // 不认它就会走 submit 提交、留口失效(BUG-40)。这一判必须在有无 carry 时都跑——首块通常无 carry。
203
- if (doneLinePrefix(t) > 0) return true;
204
- if (carry) {
205
- const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), t]);
206
- // carry 是**上一块残缺的一行**,只有本块恰好补完它(成为完整终止帧,或仍是其前缀)才算续块。
207
- // 否则本块与 carry 无关(如半截行后跟一个独立空帧):按序先放 carry、本块单独判,
208
- // 硬拼会把两个无关的帧粘成非法行。
209
- if (doneLinePrefix(joined) > 0 || splitTrailingDone(joined)) return true;
210
- }
211
- return isTrivialFrame(t.toString("utf8"));
212
- },
213
- /**
214
- * 提交:按序补写 pending(不含 tail),再写本帧,此后一律直写。
215
- * 夹带的 `[DONE]` 先切进 tail 槽:chunk 边界≠帧边界,末个内容帧常与 `[DONE]` 同 chunk 到达,
216
- * 整体直写会让终止符抢先于截断错误帧(见 splitTrailingDone)。
217
- */
218
- submit(buf, size) {
219
- committed = true;
220
- drainPending();
221
- // 上一块末尾扣着半个终止帧行:先拼上,整帧判过再决定去留
222
- let b = buf;
223
- if (carry) {
224
- const joined = Buffer.concat([carry.buf, toBuf(buf) ?? Buffer.alloc(0)]);
225
- b = joined;
226
- carry = null;
227
- }
228
- const sp = splitTrailingDone(b);
229
- if (sp) {
230
- if (sp.head.length) writeNow(sp.head, sp.head.length);
231
- if (tail) flushTail(); // 极端:本帧前已有暂扣的 [DONE],保序先补
232
- tail = { buf: sp.rest, size: sp.rest.length };
233
- return;
234
- }
235
- // 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块,别让它抢先入下游
236
- const pl = doneLinePrefix(b);
237
- if (pl > 0 && pl < b.length) {
238
- const keep = b.subarray(b.length - pl);
239
- const head = b.subarray(0, b.length - pl);
240
- if (head.length) writeNow(head, head.length);
241
- carry = { buf: keep, size: keep.length };
242
- return;
243
- }
244
- if (pl > 0) { carry = { buf: b, size: b.length }; return; } // 整块就是半截行
245
- writeNow(b, b.length);
246
- },
247
- /**
248
- * 收场兜底:扣着的半截行(若上游就此断了,它就是最后一帧)必须原样放出去,不能吞字节。
249
- * 补换行的理由见模块内 flushCarry。
250
- */
251
- flushCarry,
252
- /** 收尾前补写 pending;tail 留给 flushTail 决定时机(错误帧要先站上去)。 */
253
- commit() {
254
- committed = true;
255
- drainPending();
256
- },
257
- flushTail,
258
- writeNow,
259
- };
260
- }
261
-
262
- /**
263
- * 收场时按到达顺序放行暂扣内容:先放半截终止行(carry),再放 `[DONE]`(tail)。
264
- * 两段都在 prefix 里,故调用方只需要一个函数——顺序不能反:carry 是先到的字节。
265
- * @returns {boolean} true = `[DONE]` 确实由这次调用写出去(收场方别再补第二个)
266
- */
267
- export function flushPrefixTail(prefix) {
268
- try { prefix.flushCarry(); } catch { /* ignore */ }
269
- try { return !!prefix.flushTail(); } catch { return false; }
270
- }
271
-
272
- /**
273
- * 终局收场:把响应「恰好一次」关掉,形状必须与已经发出去的东西相容。
274
- * - headers 未 flush → 502 JSON(干净的 OpenAI 形错误);
275
- * - headers 已 flush(hold 期间发过 keepalive 注释帧,状态码锁死 200 text/event-stream)
276
- * → SSE 错误帧 + [DONE]:绝不把 JSON 体混进 event-stream,也绝不抛 ERR_HTTP_HEADERS_SENT。
277
- * 错误文案带 EMPTY_MODEL_RESPONSE 前缀(与 errors.log / isEmptyTurnError 判据同源,可 grep 对账)。
278
- */
279
- export function endHeldFailure(res, message, flushTail) {
280
- if (!res || res.writableEnded) return false;
281
- const msg = String(message || "EMPTY_MODEL_RESPONSE: empty turn, no recovery");
282
- if (!res.headersSent) {
283
- // headers 都没发出去 ⇒ 这一发的暂扣帧全部作废(它们属于失败的那一发,客户端从没收到)
284
- json(res, 502, { error: msg });
285
- return true;
286
- }
287
- return closeSseWithError(res, msg, flushTail);
288
- }
289
-
290
- /**
291
- * 已经提交过内容、但正文为零的流(现网主流:思考刷满 max_tokens):错误帧必须排在
292
- * 上游的 `[DONE]` **之前**,否则客户端一见 [DONE] 就收尾,原因永不可见。
293
- * flushTail = 把暂扣的 [DONE] 补写出去(由调用方在错误帧之后、end() 之前触发)。
294
- */
295
- export function endEmptyTurnStream(res, message, flushTail) {
296
- if (!res || res.writableEnded) return false;
297
- return closeSseWithError(res, String(message || "EMPTY_MODEL_RESPONSE: empty turn"), flushTail);
298
- }
299
-
300
- /**
301
- * 截断收场(判据 + 收场一起,供 relay 的断流分支一行调用)。
302
- *
303
- * 触发形态:流已写出真实内容、却**从未收到上游的终止标记**(`sawFinishReason=null`)就被中断
304
- * —— 超时腰斩、上游连接断、`upstream-error` 都走这里。旧行为只补一个 `[DONE]` 再按 200 成功返回,
305
- * 于是客户端既无错误帧也无 finish_reason,**分不出「答完了」与「被砍断」**,会把半截回答当完整结果
306
- * 继续用(比直接报错更危险);calls.log 也把这次事故记成一次成功的慢请求。
307
- * 现网取证:300s 腰斩 2012 块 / 416KB 输出,errors.log 零记录、calls.log 记 200。
308
- *
309
- * 收场形状与空轮一致(错误帧 → 原 [DONE] → end),type 另标以便客户端区分两种失败。
310
- * @returns {boolean} true = 已按「截断」收场;false = 流本来就有终止标记,调用方照旧补 [DONE]
311
- */
312
- export function endTruncatedStream(res, detail, flushTail) {
313
- if (!res || res.writableEnded) return false;
314
- if (detail?.sawFinishReason) return false; // 上游交代过结局,不算截断
315
- const d = detail || {};
316
- const why = d.upstreamError || d.exitReason || "stream ended";
317
- const msg = `STREAM_TRUNCATED: 上游在收到 ${d.receivedChunks || 0} 块 / ${d.receivedBytes || 0} 字节后中断,`
318
- + `未发出 finish_reason(${why})— 回答可能在半途被截断`;
319
- if (detail) { detail.truncated = true; detail.exitReason = "truncated"; detail.upstreamErrorText = d.upstreamErrorText || msg; }
320
- closeSseWithError(res, msg, flushTail, "mslxdff_truncated");
321
- return true;
322
- }
323
-
324
- /**
325
- * SSE 收场:错误帧 → 上游原 [DONE](若被暂扣过)→ end。
326
- * 铁律:终止符只能有一个,且必须排在错误帧之后 —— 否则客户端在错误帧到达前就已收尾。
327
- */
328
- function closeSseWithError(res, msg, flushTail, type = "mslxdff_empty_turn") {
329
- try {
330
- res.write(`data: ${JSON.stringify({ error: { message: msg, type } })}\n\n`);
331
- } catch { /* 下游已断:收不到是它的权利 */ }
332
- let emittedDone = false;
333
- try { emittedDone = typeof flushTail === "function" ? !!flushTail() : false; } catch { /* ignore */ }
334
- if (!emittedDone) { try { res.write("data: [DONE]\n\n"); } catch { /* ignore */ } }
335
- try { res.end(); } catch { /* ignore */ }
336
- return true;
337
- }
1
+ // 空轮的可挽救性策略(relay 的三个问题在这里回答,stream.js 只负责转发循环):
2
+ // ① 哪些帧还能撤销?—— 一次尝试在「出现任何模型产出」之前写下去的只有空 delta 帧与 [DONE],
3
+ // 这些字节一旦入下游就撤不回(重拉的正文会接在一个已完成的流后面),所以一律暂扣;
4
+ // 攒爆上限(异常形状)才被迫提交,行为退回改前。
5
+ // ② 撤销窗口留不留?—— 流结束仍零产出且下游还活着 → 不 res.end(),把这次尝试交回同模型重拉 /
6
+ // 换候选,让后面那发的正文还能顺着同一条连接送出去。
7
+ // ③ 全部救不回来怎么收场?—— 恰好一次、形状与已发出的字节相容(见 endHeldFailure / endEmptyTurnStream)。
8
+ // 另有一条次序铁律:`[DONE]` 是流的终点,客户端解析器一见它就收尾 —— 所以**任何要补在它前面的错误帧
9
+ // 都必须先把 [DONE] 暂扣住**(tail 槽),否则错误永不可见、还会留下两个终止符。
10
+ // 拆出原因:这些策略与 relay 本体同处一文件会破仓库 20KB 硬门,且语义本就独立(不碰上游读取与闸门计时)。
11
+ import { json } from "./helpers.js";
12
+ import { isTrivialFrame } from "./stream-scan.js";
13
+
14
+ // 暂扣上限:正常空轮的前缀就是几帧空 delta + 一个 [DONE];超过即说明不是这个形状,别为它囤内存。
15
+ export const HOLD_MAX_FRAMES = 64;
16
+ export const HOLD_MAX_BYTES = 32 * 1024;
17
+
18
+ /**
19
+ * 把一个 chunk 里夹带的 `[DONE]` 终止帧切下来:返回 { head, rest },head = 该帧之前的全部字节,
20
+ * rest = 从 `data: [DONE]` 那一行的行首到 chunk 末尾。
21
+ *
22
+ * 为什么必须在 submit 之前切:网络 chunk 边界与 SSE 帧边界**无关**(TCP 分段事实),上游常把末个
23
+ * 内容帧与 `data: [DONE]` 一次 flush 送进同一个 chunk。这种帧若整体直写,`[DONE]` 就抢在错误帧
24
+ * 之前到达客户端 —— 客户端一见 `[DONE]` 即收尾,截断错误帧永远看不见(BUG-17 的修复在这类形态下
25
+ * 完全失效),且收场还会因为 tail 槽空着而补出第二个 `[DONE]`。故 `[DONE]` 必须扣进 tail 槽,
26
+ * 由 flushTail 决定它的最终时机(错误帧之后)。
27
+ *
28
+ * 必须**行锚定**:正文里出现字面量 `[DONE]`(本仓域内很常见——讨论 SSE 的对话)绝不能误判成终止帧,
29
+ * 故只认「整行 trims 后等于 `data: [DONE]`」。扫描走 latin1(1 字节↔1 字符),索引与字节一一对应,
30
+ * 不受多字节字符切分影响。
31
+ */
32
+ function toBuf(buf) {
33
+ if (typeof buf === "string") return Buffer.from(buf, "utf8");
34
+ if (Buffer.isBuffer(buf)) return buf;
35
+ if (buf instanceof Uint8Array) return Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
36
+ return null;
37
+ }
38
+ const DONE_LINE_RE = /(^|\n)[ \t]*data:[ \t]*\[DONE\][ \t]*\r?(?=\n|$)/g;
39
+ function splitTrailingDone(buf) {
40
+ const b = toBuf(buf);
41
+ if (!b) return null;
42
+ const s = b.toString("latin1");
43
+ DONE_LINE_RE.lastIndex = 0;
44
+ let last = null;
45
+ for (let m; (m = DONE_LINE_RE.exec(s)); ) last = m; // 取最后一个终止帧行
46
+ if (!last) return null;
47
+ const head = last.index + (last[1] === "\n" ? 1 : 0);
48
+ return { head: b.subarray(0, head), rest: b.subarray(head) };
49
+ }
50
+
51
+ /**
52
+ * 这个 chunk 的**最后一个未结束行**像不像 `data: [DONE]` 的半截?
53
+ *
54
+ * 上面的行锚定只解决「完整终止帧与正文同 chunk」,解决不了**终止帧自己被 TCP 切开**
55
+ * (`…data: [DO` | `NE]\n\n` 两个 chunk)。这种形态下行锚定永远匹配不到 ⇒ 终止符照样
56
+ * 抢先于错误帧、收场照样补出第二个 `[DONE]`(实测复现)。故凡尾部是终止行前缀的,
57
+ * 先扣住等下一块拼上再判;下一块到齐后发现不是终止帧,立刻原样放行。
58
+ *
59
+ * 只认「去掉行首空白后是 `data: [DONE]` 的字面前缀」,且长度有上限(半个帧不会很长),
60
+ * 避免把普通正文尾巴扣住。
61
+ */
62
+ const MAX_DONE_LINE_LEN = 32;
63
+ function doneLinePrefix(buf) {
64
+ const s = buf.toString("latin1");
65
+ const nl = s.lastIndexOf("\n");
66
+ const tail = s.slice(nl + 1); // 最后一个 \n 之后 = 本 chunk 未结束的那一行
67
+ if (!tail || tail.length > MAX_DONE_LINE_LEN) return 0;
68
+ const t = tail.replace(/^[ \t]+/, "").toLowerCase().replace(/[ \t]+/g, " ");
69
+ if (!t) return 0;
70
+ // `data: [DONE]` 与 `data:[DONE]` 两种空白写法都可能是上游形式
71
+ if ("data: [done]".startsWith(t) || "data:[done]".startsWith(t)) return Buffer.byteLength(tail, "latin1");
72
+ return 0;
73
+ }
74
+
75
+ // 空轮「延后封口 + 前缀暂扣」总开关(默认开)。0/off/false = 关:连缓冲都不做,流一结束立即 end(),
76
+ // 逐字节复现改前行为(代价:之后的空轮重拉/换候选写的是一次已终结的响应,只省额度、救不回正文)。
77
+ export function holdEndEnabled() {
78
+ const s = String(process.env.MSLXDFF_EMPTY_TURN_HOLD_END ?? "").trim().toLowerCase();
79
+ return !(s === "0" || s === "off" || s === "false");
80
+ }
81
+
82
+ /**
83
+ * 可撤销前缀缓冲。帧分三类:
84
+ * - 注释帧(非数据帧):直写,不影响可撤销性(客户端视作噪声);
85
+ * - 可撤销前缀(空 delta / finish-only 帧):暂扣,出现模型产出时按序补写;
86
+ * - `[DONE]`:即使已提交也暂扣在 tail 槽,直到确认没有错误帧要补在它前面。
87
+ * 记账(detail.wroteChunks/wroteBytes)由本模块负责,调用方不必自己数字节。
88
+ */
89
+ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES, maxBytes = HOLD_MAX_BYTES } = {}) {
90
+ let pending = [];
91
+ let pendingBytes = 0;
92
+ let tail = null; // [DONE] 槽:必须最后写,且要让位给错误帧
93
+ let carry = null; // 半截 `data: [DONE]` 行:等下一块拼上再判(见 doneLinePrefix)
94
+ let committed = false;
95
+ let draining = false;
96
+ let inCarryFlush = false;
97
+ const writeNow = (buf, size) => {
98
+ // carry 是**先到**的字节:任何直写都必须等它先出,否则下游看到乱序帧。
99
+ // 之前只有 submit() 自己消化 carry,hold() 的两个分支与注释帧直写都绕过了它(实测确认)。
100
+ if (!inCarryFlush && carry) flushCarry();
101
+ if (!draining && tail) flushTail(); // 保持帧序:先补已暂扣的 [DONE],再写新到的
102
+ detail.wroteChunks += 1;
103
+ detail.wroteBytes += size;
104
+ try { res.write(buf); } catch { /* 下游已断开:onClose 已掐上游 */ }
105
+ };
106
+ /**
107
+ * 放行扣着的半截行。它若没以换行结尾**必须补一个** —— 否则随后的错误帧 / `[DONE]` 会粘在同一行上
108
+ * (`data: [DOdata: [DONE]`):既不是合法 SSE 行、客户端也认不出终止符(实测终止符数=0,比多一个更糟)。
109
+ */
110
+ const flushCarry = () => {
111
+ if (!carry) return false;
112
+ const c = carry;
113
+ carry = null;
114
+ const endsWithNl = c.size > 0 && c.buf[c.size - 1] === 0x0a;
115
+ const b = endsWithNl
116
+ ? (toBuf(c.buf) ?? Buffer.alloc(0))
117
+ : Buffer.concat([toBuf(c.buf) ?? Buffer.alloc(0), Buffer.from("\n", "utf8")]);
118
+ inCarryFlush = true;
119
+ try { writeNow(b, b.length); } finally { inCarryFlush = false; }
120
+ return true;
121
+ };
122
+ const drainPending = () => {
123
+ draining = true;
124
+ try {
125
+ for (const p of pending) writeNow(p.buf, p.size);
126
+ } finally {
127
+ draining = false;
128
+ }
129
+ pending = [];
130
+ pendingBytes = 0;
131
+ };
132
+ const flushTail = () => {
133
+ if (!tail) return false;
134
+ const t = tail;
135
+ tail = null;
136
+ detail.wroteChunks += 1;
137
+ detail.wroteBytes += t.size;
138
+ try { res.write(t.buf); } catch { /* 下游已断开 */ }
139
+ return true; // 告诉收场方:[DONE] 已经由这一帧发出去了,别再补第二个
140
+ };
141
+ /**
142
+ * 暂扣一帧。终止帧进 tail 槽(无论是否已提交);否则进 pending(未提交时可整段撤销)。
143
+ * 返回 false = pending 超上限放不下,调用方必须改为提交,别默默丢字节。
144
+ *
145
+ * **必须自己拼 carry**:上一块的半截终止行扣在 carry 里,本块就是它的续块。若让调用方分别处理,
146
+ * 两块会走各自的路径(carry 被收场 `flushCarry` 补一个换行 → 终止帧被拆成两行、客户端认不出终止符),
147
+ * 更糟的是**本块会因「不含 data:」被判成非空帧而被迫提交**,直接摧毁空轮留口(BUG-40 实测:
148
+ * 空轮 + `[DONE]` 被 TCP 切两块时 heldOpen 由 true 变 false、wrotePayload 由 false 变 true,
149
+ * 救援静默失效)。拼接是字节流的本分:carry 之后紧跟的必然是该行的续字节。
150
+ */
151
+ function holdFrame(buf, size) {
152
+ let b = buf;
153
+ if (carry) {
154
+ // 只有「本块恰好补完那个终止行」才该拼;否则 carry 里是上一块**残缺的一行**,
155
+ // 必须按序先放出去(writeNow 会补换行),硬拼会把它与本帧粘成 `data: [DOdata: {...` ——
156
+ // 非法行,客户端认不出终止符(实测终止符数=0)。已提交时更是如此:此时本帧要直写。
157
+ const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), toBuf(buf) ?? Buffer.alloc(0)]);
158
+ if (committed && !splitTrailingDone(joined) && doneLinePrefix(joined) === 0) {
159
+ writeNow(buf, size); // writeNow 内部先 flushCarry(带换行),帧序与行完整性都保住
160
+ return true;
161
+ }
162
+ b = joined;
163
+ carry = null;
164
+ size = b.length;
165
+ }
166
+ // 本块夹带终止帧:头段照常走本函数,终止帧扣进 tail(行锚定,正文里的字面量不算)
167
+ const sp = splitTrailingDone(b);
168
+ if (sp) {
169
+ if (sp.head.length && !holdFrame(sp.head, sp.head.length)) return false;
170
+ if (tail) flushTail();
171
+ tail = { buf: sp.rest, size: sp.rest.length };
172
+ return true;
173
+ }
174
+ // 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块拼上再判
175
+ const pl = doneLinePrefix(b);
176
+ if (pl > 0) {
177
+ const head = b.subarray(0, b.length - pl);
178
+ if (head.length && !holdFrame(head, head.length)) return false;
179
+ const keep = pl < b.length ? b.subarray(b.length - pl) : b;
180
+ carry = { buf: keep, size: keep.length };
181
+ return true;
182
+ }
183
+ if (committed) { writeNow(b, size); return true; }
184
+ if (pending.length >= maxFrames || pendingBytes + size > maxBytes) return false;
185
+ pending.push({ buf: b, size });
186
+ pendingBytes += size;
187
+ return true;
188
+ }
189
+ return {
190
+ get committed() { return committed; },
191
+ get heldFrames() { return pending.length + (tail ? 1 : 0); },
192
+ hold: holdFrame,
193
+ /**
194
+ * 本块是否仍属「可撤销前缀」——必须连同 carry 一起判,且**半截终止行本身也算**。
195
+ * 只看本块会漏两种:① 续块(`NE]\n\n`)自身不含 `data:`,`isTrivialFrame` 必判 false;
196
+ * ② 首块(`data: [DO`)自身是半个终止帧,JSON 解析失败也判 false。两者都会让调用方走 submit
197
+ * 提交、留口失效(BUG-40)。凡尾部是终止行前缀的一律按可暂扣处理:它会进 carry 等下一块。
198
+ */
199
+ isTrivial(txt) {
200
+ const t = Buffer.from(String(txt ?? ""), "utf8");
201
+ // 尾部是终止行前缀(`data: [DO`):它是**残缺的一行**,会被扣进 carry 等续块,本身不含模型产出。
202
+ // 不认它就会走 submit 提交、留口失效(BUG-40)。这一判必须在有无 carry 时都跑——首块通常无 carry。
203
+ if (doneLinePrefix(t) > 0) return true;
204
+ if (carry) {
205
+ const joined = Buffer.concat([toBuf(carry.buf) ?? Buffer.alloc(0), t]);
206
+ // carry 是**上一块残缺的一行**,只有本块恰好补完它(成为完整终止帧,或仍是其前缀)才算续块。
207
+ // 否则本块与 carry 无关(如半截行后跟一个独立空帧):按序先放 carry、本块单独判,
208
+ // 硬拼会把两个无关的帧粘成非法行。
209
+ if (doneLinePrefix(joined) > 0 || splitTrailingDone(joined)) return true;
210
+ }
211
+ return isTrivialFrame(t.toString("utf8"));
212
+ },
213
+ /**
214
+ * 提交:按序补写 pending(不含 tail),再写本帧,此后一律直写。
215
+ * 夹带的 `[DONE]` 先切进 tail 槽:chunk 边界≠帧边界,末个内容帧常与 `[DONE]` 同 chunk 到达,
216
+ * 整体直写会让终止符抢先于截断错误帧(见 splitTrailingDone)。
217
+ */
218
+ submit(buf, size) {
219
+ committed = true;
220
+ drainPending();
221
+ // 上一块末尾扣着半个终止帧行:先拼上,整帧判过再决定去留
222
+ let b = buf;
223
+ if (carry) {
224
+ const joined = Buffer.concat([carry.buf, toBuf(buf) ?? Buffer.alloc(0)]);
225
+ b = joined;
226
+ carry = null;
227
+ }
228
+ const sp = splitTrailingDone(b);
229
+ if (sp) {
230
+ if (sp.head.length) writeNow(sp.head, sp.head.length);
231
+ if (tail) flushTail(); // 极端:本帧前已有暂扣的 [DONE],保序先补
232
+ tail = { buf: sp.rest, size: sp.rest.length };
233
+ return;
234
+ }
235
+ // 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块,别让它抢先入下游
236
+ const pl = doneLinePrefix(b);
237
+ if (pl > 0 && pl < b.length) {
238
+ const keep = b.subarray(b.length - pl);
239
+ const head = b.subarray(0, b.length - pl);
240
+ if (head.length) writeNow(head, head.length);
241
+ carry = { buf: keep, size: keep.length };
242
+ return;
243
+ }
244
+ if (pl > 0) { carry = { buf: b, size: b.length }; return; } // 整块就是半截行
245
+ writeNow(b, b.length);
246
+ },
247
+ /**
248
+ * 收场兜底:扣着的半截行(若上游就此断了,它就是最后一帧)必须原样放出去,不能吞字节。
249
+ * 补换行的理由见模块内 flushCarry。
250
+ */
251
+ flushCarry,
252
+ /** 收尾前补写 pending;tail 留给 flushTail 决定时机(错误帧要先站上去)。 */
253
+ commit() {
254
+ committed = true;
255
+ drainPending();
256
+ },
257
+ flushTail,
258
+ writeNow,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * 收场时按到达顺序放行暂扣内容:先放半截终止行(carry),再放 `[DONE]`(tail)。
264
+ * 两段都在 prefix 里,故调用方只需要一个函数——顺序不能反:carry 是先到的字节。
265
+ * @returns {boolean} true = `[DONE]` 确实由这次调用写出去(收场方别再补第二个)
266
+ */
267
+ export function flushPrefixTail(prefix) {
268
+ try { prefix.flushCarry(); } catch { /* ignore */ }
269
+ try { return !!prefix.flushTail(); } catch { return false; }
270
+ }
271
+
272
+ /**
273
+ * 终局收场:把响应「恰好一次」关掉,形状必须与已经发出去的东西相容。
274
+ * - headers 未 flush → 502 JSON(干净的 OpenAI 形错误);
275
+ * - headers 已 flush(hold 期间发过 keepalive 注释帧,状态码锁死 200 text/event-stream)
276
+ * → SSE 错误帧 + [DONE]:绝不把 JSON 体混进 event-stream,也绝不抛 ERR_HTTP_HEADERS_SENT。
277
+ * 错误文案带 EMPTY_MODEL_RESPONSE 前缀(与 errors.log / isEmptyTurnError 判据同源,可 grep 对账)。
278
+ */
279
+ export function endHeldFailure(res, message, flushTail) {
280
+ if (!res || res.writableEnded) return false;
281
+ const msg = String(message || "EMPTY_MODEL_RESPONSE: empty turn, no recovery");
282
+ if (!res.headersSent) {
283
+ // headers 都没发出去 ⇒ 这一发的暂扣帧全部作废(它们属于失败的那一发,客户端从没收到)
284
+ json(res, 502, { error: msg });
285
+ return true;
286
+ }
287
+ return closeSseWithError(res, msg, flushTail);
288
+ }
289
+
290
+ /**
291
+ * 已经提交过内容、但正文为零的流(现网主流:思考刷满 max_tokens):错误帧必须排在
292
+ * 上游的 `[DONE]` **之前**,否则客户端一见 [DONE] 就收尾,原因永不可见。
293
+ * flushTail = 把暂扣的 [DONE] 补写出去(由调用方在错误帧之后、end() 之前触发)。
294
+ */
295
+ export function endEmptyTurnStream(res, message, flushTail) {
296
+ if (!res || res.writableEnded) return false;
297
+ return closeSseWithError(res, String(message || "EMPTY_MODEL_RESPONSE: empty turn"), flushTail);
298
+ }
299
+
300
+ /**
301
+ * 截断收场(判据 + 收场一起,供 relay 的断流分支一行调用)。
302
+ *
303
+ * 触发形态:流已写出真实内容、却**从未收到上游的终止标记**(`sawFinishReason=null`)就被中断
304
+ * —— 超时腰斩、上游连接断、`upstream-error` 都走这里。旧行为只补一个 `[DONE]` 再按 200 成功返回,
305
+ * 于是客户端既无错误帧也无 finish_reason,**分不出「答完了」与「被砍断」**,会把半截回答当完整结果
306
+ * 继续用(比直接报错更危险);calls.log 也把这次事故记成一次成功的慢请求。
307
+ * 现网取证:300s 腰斩 2012 块 / 416KB 输出,errors.log 零记录、calls.log 记 200。
308
+ *
309
+ * 收场形状与空轮一致(错误帧 → 原 [DONE] → end),type 另标以便客户端区分两种失败。
310
+ * @returns {boolean} true = 已按「截断」收场;false = 流本来就有终止标记,调用方照旧补 [DONE]
311
+ */
312
+ export function endTruncatedStream(res, detail, flushTail) {
313
+ if (!res || res.writableEnded) return false;
314
+ if (detail?.sawFinishReason) return false; // 上游交代过结局,不算截断
315
+ const d = detail || {};
316
+ const why = d.upstreamError || d.exitReason || "stream ended";
317
+ const msg = `STREAM_TRUNCATED: 上游在收到 ${d.receivedChunks || 0} 块 / ${d.receivedBytes || 0} 字节后中断,`
318
+ + `未发出 finish_reason(${why})— 回答可能在半途被截断`;
319
+ if (detail) { detail.truncated = true; detail.exitReason = "truncated"; detail.upstreamErrorText = d.upstreamErrorText || msg; }
320
+ closeSseWithError(res, msg, flushTail, "mslxdff_truncated");
321
+ return true;
322
+ }
323
+
324
+ /**
325
+ * SSE 收场:错误帧 → 上游原 [DONE](若被暂扣过)→ end。
326
+ * 铁律:终止符只能有一个,且必须排在错误帧之后 —— 否则客户端在错误帧到达前就已收尾。
327
+ */
328
+ function closeSseWithError(res, msg, flushTail, type = "mslxdff_empty_turn") {
329
+ try {
330
+ res.write(`data: ${JSON.stringify({ error: { message: msg, type } })}\n\n`);
331
+ } catch { /* 下游已断:收不到是它的权利 */ }
332
+ let emittedDone = false;
333
+ try { emittedDone = typeof flushTail === "function" ? !!flushTail() : false; } catch { /* ignore */ }
334
+ if (!emittedDone) { try { res.write("data: [DONE]\n\n"); } catch { /* ignore */ } }
335
+ try { res.end(); } catch { /* ignore */ }
336
+ return true;
337
+ }