mslxdff 0.1.231 → 0.1.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mslxdff.js +3 -3
- package/package.json +1 -1
- package/src/auto.js +264 -254
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +155 -145
- package/src/chat-pipeline/policy.js +84 -84
- package/src/cli/commands/group.js +266 -266
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +139 -139
- package/src/cli/commands/provider/models.js +149 -149
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/raccoon-quota.js +156 -156
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/status.js +279 -279
- package/src/daemon.js +246 -246
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +10 -2
- package/src/models.js +292 -225
- package/src/peers.js +345 -345
- package/src/providers/agentrouter.js +238 -238
- package/src/providers/base.js +164 -164
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +207 -207
- package/src/providers/dispatcher.js +147 -147
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/account-store.js +18 -0
- package/src/providers/qoder/chat.js +292 -203
- package/src/providers/qoder/checkin.js +25 -12
- package/src/providers/qoder/index.js +311 -249
- package/src/providers/qoder/models.js +64 -2
- package/src/providers/qoder/oauth.js +42 -1
- package/src/providers/qoder/payload.js +116 -80
- package/src/providers/qoder/request.js +7 -2
- package/src/providers/qoder/session.js +26 -10
- package/src/providers/qoder/sse.js +233 -145
- package/src/providers/qoder/sticky.js +5 -2
- package/src/providers/qoder/wire-messages.js +242 -0
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +337 -337
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/raccoon/chat.js +1 -1
- package/src/providers/raccoon/const.js +219 -202
- package/src/providers/raccoon/credits.js +285 -285
- package/src/providers/raccoon/index.js +15 -2
- package/src/providers/raccoon/models.js +68 -17
- package/src/providers/registry.js +89 -89
- package/src/providers/workbuddy/auth.js +16 -0
- package/src/providers/workbuddy/chat.js +298 -264
- package/src/providers/workbuddy/client-headers.js +148 -0
- package/src/providers/workbuddy/desktop-info.js +17 -1
- package/src/providers/workbuddy/models.js +8 -20
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +80 -80
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +282 -282
- package/src/routes/chat/via-route-handler.js +156 -156
- package/src/routes/hedge.js +260 -260
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +373 -373
- package/src/routes/stream.js +364 -347
- package/src/runtime/bootstrap.js +75 -75
- package/src/runtime/keep-warm.js +135 -135
- package/src/runtime/provider-gate.js +75 -75
- package/src/runtime/providers-setup.js +187 -187
- package/src/runtime/raccoon-checkin.js +175 -175
- package/src/server.js +85 -85
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +355 -355
- package/docs/cli_help_mini.md +0 -148
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
package/src/routes/stream.js
CHANGED
|
@@ -1,347 +1,364 @@
|
|
|
1
|
-
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { applyFallbackHeaders } from "./fallback.js";
|
|
3
|
-
import { json } from "./helpers.js";
|
|
4
|
-
import { scanSseChunk, flushScanRemainder, preflightMs, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk } from "./stream-scan.js"; // 逐帧观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣(纯函数与建桶判据全在 stream-scan;可撤销判据走 prefix.isTrivial)
|
|
5
|
-
import { holdEndEnabled, createRevocablePrefix, endEmptyTurnStream, endTruncatedStream, flushPrefixTail } from "./stream-hold.js"; // 空轮可撤销前缀 / 延后封口 / 终局收场(拆出去是为了不破 stream.js 20KB 硬门)
|
|
6
|
-
import { chunkText, cancelBody, hasPayload, bodyChunks, enrichFirstReply, scanNonStreamReply } from "./stream-body.js"; // 读取层工具(字节→文本 / 取消上游 / 注释帧判定 / 自持 reader 迭代 / 首块富化 / 非流式先判后写),为让本文件退回 20KB 以内而拆出
|
|
7
|
-
|
|
8
|
-
export const SLOW_TOTAL_MS = (() => {
|
|
9
|
-
const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
|
|
10
|
-
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
11
|
-
})();
|
|
12
|
-
|
|
13
|
-
export const STREAM_TIMEOUT_MS = (() => {
|
|
14
|
-
const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
|
|
15
|
-
// 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
|
|
16
|
-
return Number.isInteger(n) && n >= 0 ? n : 25_000;
|
|
17
|
-
})();
|
|
18
|
-
|
|
19
|
-
// 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
|
|
20
|
-
export const KEEPALIVE_MS = (() => {
|
|
21
|
-
const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
|
|
22
|
-
return Number.isInteger(n) && n >= 0 ? n : 10_000;
|
|
23
|
-
})();
|
|
24
|
-
|
|
25
|
-
export const STALL_TIMEOUT_MS = (() => {
|
|
26
|
-
const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
|
|
27
|
-
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
28
|
-
})();
|
|
29
|
-
|
|
30
|
-
export const SCORE_STALL_MS = (() => {
|
|
31
|
-
const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
|
|
32
|
-
const n = Number(raw);
|
|
33
|
-
return Number.isInteger(n) && n > 0 ? n : 15_000;
|
|
34
|
-
})();
|
|
35
|
-
|
|
36
|
-
// 流式总时长上限。**默认关闭(0)是刻意且有实测依据的**:agent 长任务单次 relay 合法地远超 120s
|
|
37
|
-
// ——扫全量模型日志 6157 条 relay-done,**103 条(1.67%)单次 >120s**,且主体是 `finish=tool_calls`
|
|
38
|
-
// 的完整成功流(实测 144s/207s/228s/249s/308s/326s/343s/449s;其中 326s 那条正文 6933 字、客户端 200)。
|
|
39
|
-
// 若默认设 120s,这些请求会被腰斩成截断——代价远大于「防挂死」的收益。
|
|
40
|
-
// 真正的挂死已有三道约束:首块闸门 STREAM_TIMEOUT_MS(25s)、provider 自身请求级超时(raccoon 300s)、
|
|
41
|
-
// 以及被腰斩时的 STREAM_TRUNCATED 错误帧(见 stream-hold.js,客户端至少能识别截断而不会误当完整结果)。
|
|
42
|
-
// 需要硬上限时显式设 MSLXDFF_MAX_STREAM_MS=<ms>;`0`=显式关闭。
|
|
43
|
-
export const MAX_STREAM_MS = (() => {
|
|
44
|
-
const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
|
|
45
|
-
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
46
|
-
})();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
detail.
|
|
219
|
-
if (detail.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (!holdErr) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { applyFallbackHeaders } from "./fallback.js";
|
|
3
|
+
import { json } from "./helpers.js";
|
|
4
|
+
import { scanSseChunk, flushScanRemainder, preflightMs, createTalkBucketIfEnabled, captureTalkFallback, isEmptyTurnDetail, holdableChunk } from "./stream-scan.js"; // 逐帧观测累加器 + 锚点偏移 + 对话正文 + 空轮判据 + 错误包络暂扣(纯函数与建桶判据全在 stream-scan;可撤销判据走 prefix.isTrivial)
|
|
5
|
+
import { holdEndEnabled, createRevocablePrefix, endEmptyTurnStream, endTruncatedStream, flushPrefixTail } from "./stream-hold.js"; // 空轮可撤销前缀 / 延后封口 / 终局收场(拆出去是为了不破 stream.js 20KB 硬门)
|
|
6
|
+
import { chunkText, cancelBody, hasPayload, bodyChunks, enrichFirstReply, scanNonStreamReply } from "./stream-body.js"; // 读取层工具(字节→文本 / 取消上游 / 注释帧判定 / 自持 reader 迭代 / 首块富化 / 非流式先判后写),为让本文件退回 20KB 以内而拆出
|
|
7
|
+
|
|
8
|
+
export const SLOW_TOTAL_MS = (() => {
|
|
9
|
+
const n = Number(process.env.MSLXDFF_SLOW_TOTAL_MS);
|
|
10
|
+
return Number.isInteger(n) && n > 0 ? n : 20_000;
|
|
11
|
+
})();
|
|
12
|
+
|
|
13
|
+
export const STREAM_TIMEOUT_MS = (() => {
|
|
14
|
+
const n = Number(process.env.MSLXDFF_STREAM_TIMEOUT_MS);
|
|
15
|
+
// 0 = 显式关闭首块超时(慢思考模型专用);未设/非法值 → 默认 25s
|
|
16
|
+
return Number.isInteger(n) && n >= 0 ? n : 25_000;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
// 等首块期间的心跳间隔(SSE 注释帧,标准客户端忽略):上游偶发卡 90s+,避免客户端误判卡死/断连
|
|
20
|
+
export const KEEPALIVE_MS = (() => {
|
|
21
|
+
const n = Number(process.env.MSLXDFF_KEEPALIVE_MS);
|
|
22
|
+
return Number.isInteger(n) && n >= 0 ? n : 10_000;
|
|
23
|
+
})();
|
|
24
|
+
|
|
25
|
+
export const STALL_TIMEOUT_MS = (() => {
|
|
26
|
+
const n = Number(process.env.MSLXDFF_STALL_TIMEOUT_MS);
|
|
27
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
export const SCORE_STALL_MS = (() => {
|
|
31
|
+
const raw = process.env.MSLXDFF_SCORE_STALL_MS ?? process.env.MSLXDFF_STALL_TIMEOUT_MS;
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
return Number.isInteger(n) && n > 0 ? n : 15_000;
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
// 流式总时长上限。**默认关闭(0)是刻意且有实测依据的**:agent 长任务单次 relay 合法地远超 120s
|
|
37
|
+
// ——扫全量模型日志 6157 条 relay-done,**103 条(1.67%)单次 >120s**,且主体是 `finish=tool_calls`
|
|
38
|
+
// 的完整成功流(实测 144s/207s/228s/249s/308s/326s/343s/449s;其中 326s 那条正文 6933 字、客户端 200)。
|
|
39
|
+
// 若默认设 120s,这些请求会被腰斩成截断——代价远大于「防挂死」的收益。
|
|
40
|
+
// 真正的挂死已有三道约束:首块闸门 STREAM_TIMEOUT_MS(25s)、provider 自身请求级超时(raccoon 300s)、
|
|
41
|
+
// 以及被腰斩时的 STREAM_TRUNCATED 错误帧(见 stream-hold.js,客户端至少能识别截断而不会误当完整结果)。
|
|
42
|
+
// 需要硬上限时显式设 MSLXDFF_MAX_STREAM_MS=<ms>;`0`=显式关闭。
|
|
43
|
+
export const MAX_STREAM_MS = (() => {
|
|
44
|
+
const n = Number(process.env.MSLXDFF_MAX_STREAM_MS);
|
|
45
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
// 上游判决头白名单透传:provider 层(qoder no-capacity/login/quota)把结论钉在响应头上,
|
|
49
|
+
// relay 这一层若只回 status 不回这些头,客户端就拿不到"该重试还是该换号"的判决。
|
|
50
|
+
// 只放白名单,绝不整包 upRes.headers(避免把上游内部头/日期等杂讯透给客户端)。
|
|
51
|
+
// 取证教训:真机 429 响应没带 Retry-After,就是因为这里丢头(见 qoder chat.js withNoCapacity)。
|
|
52
|
+
const DECISION_HEADERS = [
|
|
53
|
+
"retry-after",
|
|
54
|
+
"x-mslxdff-qoder-verdict",
|
|
55
|
+
"x-mslxdff-qoder-nocapacity",
|
|
56
|
+
"x-mslxdff-qoder-retry-after",
|
|
57
|
+
"x-mslxdff-qoder-login",
|
|
58
|
+
"x-mslxdff-workbuddy-uid",
|
|
59
|
+
"x-mslxdff-workbuddy-reason",
|
|
60
|
+
"x-mslxdff-allowlist",
|
|
61
|
+
"x-mslxdff-upstream-engine",
|
|
62
|
+
];
|
|
63
|
+
function propagateDecisionHeaders(res, upRes) {
|
|
64
|
+
try {
|
|
65
|
+
for (const name of DECISION_HEADERS) {
|
|
66
|
+
const v = upRes.headers.get(name);
|
|
67
|
+
if (v != null && v !== "") res.setHeader(name, v);
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback, attemptStartMs } = {}) {
|
|
73
|
+
const t0 = performance.now();
|
|
74
|
+
// 上报锚点偏移:只加给「上报用时长」(usage 行),闸门计时仍从上面的 t0 起算,两者不得混用
|
|
75
|
+
const preflight = preflightMs(t0, attemptStartMs);
|
|
76
|
+
const contentType = upRes.headers.get("content-type") || "";
|
|
77
|
+
// 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
|
|
78
|
+
const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
|
|
79
|
+
// 重入同一条连接(空轮留口后同模型重拉 / 换候选)时 headers 可能已 flush(前一发发过 keepalive 注释帧):
|
|
80
|
+
// 此时 statusCode 与自定义头都不可再设,否则 setHeader 抛 ERR_HTTP_HEADERS_SENT,重拉直接炸在半路
|
|
81
|
+
if (!res.headersSent) {
|
|
82
|
+
res.statusCode = upRes.status;
|
|
83
|
+
// propagate decision headers(workbuddy uid/allowlist + qoder 判决 + Retry-After)
|
|
84
|
+
propagateDecisionHeaders(res, upRes);
|
|
85
|
+
if (fallback) applyFallbackHeaders(res, fallback);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let ttf = null;
|
|
89
|
+
let interrupted = false;
|
|
90
|
+
let finishedNormally = false;
|
|
91
|
+
let commitPendingPrefix = () => {};
|
|
92
|
+
let flushHeldTail = () => {}; // 把暂扣的 [DONE] 补写出去(错误帧必须能先站在它前面)
|
|
93
|
+
const detail = {
|
|
94
|
+
receivedChunks: 0,
|
|
95
|
+
receivedBytes: 0,
|
|
96
|
+
wroteChunks: 0,
|
|
97
|
+
wroteBytes: 0,
|
|
98
|
+
sawDone: false,
|
|
99
|
+
sawFinishReason: null,
|
|
100
|
+
lastChunkAtMs: null,
|
|
101
|
+
lastChunkGapMs: null,
|
|
102
|
+
maxGapMs: 0,
|
|
103
|
+
stallHits: 0,
|
|
104
|
+
exitReason: null,
|
|
105
|
+
upstreamError: null,
|
|
106
|
+
downstreamClosed: false,
|
|
107
|
+
usage: null,
|
|
108
|
+
chars: 0,
|
|
109
|
+
reasoningChars: 0, // 思考内容字符数(与 chars 分列,相加会重复计数)
|
|
110
|
+
toolCalls: 0,
|
|
111
|
+
chatShaped: false,
|
|
112
|
+
talk: createTalkBucketIfEnabled(), // 正文桶:talk.log 或回路捕获任一开就建,上限单一真相 talkCapChars()
|
|
113
|
+
heldErrorChunks: 0,
|
|
114
|
+
upstreamErrorText: null,
|
|
115
|
+
recoveries: 0,
|
|
116
|
+
wrotePayload: false, // 是否已向下游写出真实数据帧(注释帧不算):决定空转还能不能靠重试救
|
|
117
|
+
};
|
|
118
|
+
let prevChunkAt = t0;
|
|
119
|
+
// 断下游即掐上游:流式分支装配真实取消,非流式保持 no-op
|
|
120
|
+
let cancelUpstream = () => {};
|
|
121
|
+
const onClose = () => {
|
|
122
|
+
detail.downstreamClosed = true;
|
|
123
|
+
if (!finishedNormally) {
|
|
124
|
+
cancelUpstream();
|
|
125
|
+
if (onDownstreamAbort) onDownstreamAbort();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
res.on("close", onClose);
|
|
129
|
+
// 入口即已断开/已收场:先落账,否则这一发会把留口当成「下游还活着」,重拉写给消失的读者
|
|
130
|
+
if (res.writableEnded || res.destroyed) detail.downstreamClosed = true;
|
|
131
|
+
|
|
132
|
+
if (isStream) {
|
|
133
|
+
// failover 重入时 headers 可能已发(前一个候选只发过 keepalive 注释帧就被掐)——已发则不可再设
|
|
134
|
+
if (!res.headersSent) {
|
|
135
|
+
try {
|
|
136
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
137
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
138
|
+
res.setHeader("Connection", "keep-alive");
|
|
139
|
+
} catch { /* ignore */ }
|
|
140
|
+
}
|
|
141
|
+
if (fallback?.fallback) {
|
|
142
|
+
try {
|
|
143
|
+
res.write(`: mslxdff fallback ${fallback.requested_model} -> ${fallback.actual_model} (${fallback.reason})\n`);
|
|
144
|
+
res.write(`: notice ${fallback.notice}\n\n`);
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
if (upRes.body) {
|
|
148
|
+
let first = true;
|
|
149
|
+
let wroteAny = false;
|
|
150
|
+
let wrotePayload = false; // 真实数据帧(注释帧不算):决定能否安全 failover
|
|
151
|
+
const revocableHold = holdEndEnabled(); // HOLD_END=0 时连缓冲都不做(逐字节复现旧行为,含时机)
|
|
152
|
+
// 可撤销前缀缓冲(策略见 stream-hold.js):模型产出出现前,空 delta 帧与 [DONE] 先不入下游,
|
|
153
|
+
// 这样同模型重拉 / 换候选的正文还能顺着同一条连接送出去;上限兜底防异常形状囤内存。
|
|
154
|
+
const prefix = createRevocablePrefix(res, detail);
|
|
155
|
+
commitPendingPrefix = () => prefix.commit();
|
|
156
|
+
flushHeldTail = () => flushPrefixTail(prefix);
|
|
157
|
+
let timedOut = false;
|
|
158
|
+
let stalled = false;
|
|
159
|
+
let tooLong = false;
|
|
160
|
+
let stallTimer = null;
|
|
161
|
+
// 自持 reader:超时/断下游时 reader.cancel() 才真的掐得断上游
|
|
162
|
+
// Note: 闸门真取消 + 注释帧不算首块 + wrotePayload 判据 — 见 .agents/notes/implemented/bug-fix/2026-09-17-relay-first-chunk-gate-real-cancel.md
|
|
163
|
+
const reader = typeof upRes.body.getReader === "function" ? upRes.body.getReader() : null;
|
|
164
|
+
let cancelled = false;
|
|
165
|
+
cancelUpstream = () => {
|
|
166
|
+
if (cancelled) return;
|
|
167
|
+
cancelled = true;
|
|
168
|
+
if (reader) { try { reader.cancel().catch(() => {}); } catch { /* ignore */ } }
|
|
169
|
+
else cancelBody(upRes.body);
|
|
170
|
+
};
|
|
171
|
+
let pingTimer = keepaliveMs > 0
|
|
172
|
+
? setInterval(() => {
|
|
173
|
+
if (wroteAny) return;
|
|
174
|
+
try { res.write(": keepalive\n\n"); } catch { /* ignore */ }
|
|
175
|
+
}, keepaliveMs)
|
|
176
|
+
: null;
|
|
177
|
+
const armStall = () => {
|
|
178
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
179
|
+
stallTimer = STALL_TIMEOUT_MS
|
|
180
|
+
? setTimeout(() => {
|
|
181
|
+
stalled = true;
|
|
182
|
+
detail.exitReason = "stall";
|
|
183
|
+
cancelUpstream();
|
|
184
|
+
}, STALL_TIMEOUT_MS)
|
|
185
|
+
: null;
|
|
186
|
+
};
|
|
187
|
+
let firstTimer = streamTimeoutMs > 0
|
|
188
|
+
? setTimeout(() => {
|
|
189
|
+
timedOut = true;
|
|
190
|
+
detail.exitReason = "first-timeout";
|
|
191
|
+
cancelUpstream();
|
|
192
|
+
}, streamTimeoutMs)
|
|
193
|
+
: null;
|
|
194
|
+
const maxTimer = MAX_STREAM_MS
|
|
195
|
+
? setTimeout(() => {
|
|
196
|
+
tooLong = true;
|
|
197
|
+
detail.exitReason = "max";
|
|
198
|
+
cancelUpstream();
|
|
199
|
+
}, MAX_STREAM_MS)
|
|
200
|
+
: null;
|
|
201
|
+
try {
|
|
202
|
+
for await (const chunk of bodyChunks(upRes.body, reader)) {
|
|
203
|
+
const now = performance.now();
|
|
204
|
+
detail.receivedChunks += 1;
|
|
205
|
+
const len = chunk?.length ?? chunk?.byteLength ?? 0;
|
|
206
|
+
detail.receivedBytes += len;
|
|
207
|
+
const gap = Math.round(now - prevChunkAt);
|
|
208
|
+
detail.lastChunkAtMs = Math.round(now - t0);
|
|
209
|
+
detail.lastChunkGapMs = gap;
|
|
210
|
+
if (gap > detail.maxGapMs) detail.maxGapMs = gap;
|
|
211
|
+
if (gap > SCORE_STALL_MS) detail.stallHits += 1;
|
|
212
|
+
prevChunkAt = now;
|
|
213
|
+
const txt = chunkText(chunk);
|
|
214
|
+
// 错误包络暂扣:本轮尚未写出真实输出时错误帧不写下游(下游 UI 不再展示瞬时错误),
|
|
215
|
+
// 摘要记 detail.upstreamErrorText 供空转判定与最终报错;已写出真实内容后的错误帧照常透传。
|
|
216
|
+
const holdErr = !wrotePayload ? holdableChunk(txt) : null;
|
|
217
|
+
if (holdErr) {
|
|
218
|
+
detail.heldErrorChunks += 1;
|
|
219
|
+
if (!detail.upstreamErrorText) detail.upstreamErrorText = String(holdErr).slice(0, 500);
|
|
220
|
+
}
|
|
221
|
+
const isPayload = hasPayload(txt);
|
|
222
|
+
const prevChars = detail.chars, prevTools = detail.toolCalls, prevReason = detail.reasoningChars;
|
|
223
|
+
scanSseChunk(detail, txt);
|
|
224
|
+
// 「模型产出」=正文/工具调用/思考三者本轮有新增。思考也算:已发出的思考撤不回,
|
|
225
|
+
// 从它出现的那刻起这次尝试就提交了(现网空轮多数正是「思考刷满、正文为零」)。
|
|
226
|
+
const gained = Number(detail.chars) > prevChars || Number(detail.toolCalls) > prevTools || Number(detail.reasoningChars) > prevReason;
|
|
227
|
+
// 首块/空闲超时后上游仍吐出了真实数据 → 只是慢,不是死:撤销超时判定,照常转发
|
|
228
|
+
//(cancel 是异步的,竞态窗口内已到达的数据是纯收益;丢掉是纯损失)
|
|
229
|
+
// 注释帧(keepalive)不算:否则对端只要在发心跳,闸门就永远解除
|
|
230
|
+
if (isPayload && !holdErr && (timedOut || stalled)) {
|
|
231
|
+
timedOut = false;
|
|
232
|
+
stalled = false;
|
|
233
|
+
detail.recoveries = (detail.recoveries || 0) + 1;
|
|
234
|
+
if (detail.exitReason === "first-timeout" || detail.exitReason === "stall") detail.exitReason = null;
|
|
235
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
236
|
+
}
|
|
237
|
+
if (timedOut || stalled || tooLong) break;
|
|
238
|
+
if (isPayload && first && !holdErr) {
|
|
239
|
+
first = false;
|
|
240
|
+
ttf = Math.round(now - t0);
|
|
241
|
+
onFirstChunk?.(ttf);
|
|
242
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
243
|
+
}
|
|
244
|
+
const outChunk = first === false && wrotePayload === false ? enrichFirstReply(chunk, fallback) : chunk;
|
|
245
|
+
if (!holdErr) {
|
|
246
|
+
const size = Buffer.isBuffer(outChunk) ? outChunk.length : (outChunk?.length ?? len);
|
|
247
|
+
if (!isPayload) {
|
|
248
|
+
// 注释帧等非数据帧:与改前一致直写(客户端视作噪声,不影响可撤销性)
|
|
249
|
+
wroteAny = true;
|
|
250
|
+
prefix.writeNow(outChunk, size);
|
|
251
|
+
} else if (gained || !revocableHold || !prefix.isTrivial(txt)) {
|
|
252
|
+
// 有模型产出、留口已关、或看不懂的异形帧:按序补写暂扣前缀 + 本帧,此流就此提交不可撤销
|
|
253
|
+
//(透传契约:解析失败的帧必须原样走,绝不为救空轮扣下)。
|
|
254
|
+
// 可撤销判定走 prefix.isTrivial 而非裸 isTrivialFrame:半截终止行扣在 prefix 的 carry 里,
|
|
255
|
+
// 只看本块会把它的续块判成非空,留口静默失效(BUG-40)。
|
|
256
|
+
wroteAny = true;
|
|
257
|
+
wrotePayload = true; detail.wrotePayload = true;
|
|
258
|
+
prefix.submit(outChunk, size);
|
|
259
|
+
} else if (!prefix.hold(outChunk, size)) {
|
|
260
|
+
// 可撤销前缀按序暂扣([DONE] 进 tail 槽让位给错误帧;终止帧识别收进 prefix,见 holdFrame)
|
|
261
|
+
wroteAny = true;
|
|
262
|
+
wrotePayload = true; detail.wrotePayload = true;
|
|
263
|
+
prefix.submit(outChunk, size); // 暂扣超上限(异常形状)→ 被迫提交,退回旧行为
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
armStall();
|
|
267
|
+
}
|
|
268
|
+
if (!detail.exitReason) detail.exitReason = detail.downstreamClosed ? "downstream-closed" : "normal";
|
|
269
|
+
} catch (err) {
|
|
270
|
+
detail.upstreamError = String(err?.message || err).slice(0, 300);
|
|
271
|
+
detail.exitReason = "upstream-error";
|
|
272
|
+
if (!wrotePayload) timedOut = true;
|
|
273
|
+
else stalled = true;
|
|
274
|
+
} finally {
|
|
275
|
+
if (firstTimer) clearTimeout(firstTimer);
|
|
276
|
+
if (maxTimer) clearTimeout(maxTimer);
|
|
277
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
278
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
279
|
+
// 末帧常无收尾换行:不回收残行,切块/缺换行的 finish_reason 就永远读不到,
|
|
280
|
+
// 下面所有依赖它的判定(截断收场、空轮判据)全都会被带偏。
|
|
281
|
+
try { flushScanRemainder(detail); } catch { /* 观测旁路:绝不外溢成转发失败 */ }
|
|
282
|
+
}
|
|
283
|
+
// 任一闸门到点且未写出真实数据 → 可安全 failover(注释帧对客户端无意义,不算已响应)
|
|
284
|
+
if ((timedOut || stalled || tooLong) && !wrotePayload) {
|
|
285
|
+
res.removeListener("close", onClose);
|
|
286
|
+
// Note: 超时是显式字段(timedOut),别再用 status 数值当信号 — 见 .agents/notes/implemented/architecture/2026-09-17-relay-timedout-explicit-and-metrics-seam.md
|
|
287
|
+
return { status: 504, timedOut: true, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: true, interrupted: false, preflightMs: preflight, detail };
|
|
288
|
+
}
|
|
289
|
+
if ((stalled || tooLong || detail.exitReason === "upstream-error") && wrotePayload) {
|
|
290
|
+
interrupted = true;
|
|
291
|
+
detail.exitReason = detail.exitReason || (stalled ? "stall" : "max");
|
|
292
|
+
// 这一段是**服务端主动掐的**,不是客户端跑的:收场后必须与正常收场同样摘掉 close 监听并置
|
|
293
|
+
// finishedNormally,否则 res.end() 触发的那次 close 会把 onClose 当「下游断开」执行,
|
|
294
|
+
// 让上层把这次截断记成一条 client-abort(`format.js` 渲染成「client abort」)——
|
|
295
|
+
// 服务端自己砍的流,错误归因到客户端身上。实测三形态对照:正常收场监听=0、截断收场监听=1。
|
|
296
|
+
finishedNormally = true;
|
|
297
|
+
res.removeListener("close", onClose);
|
|
298
|
+
// 上游从未给过终止标记就断了 ⇒ 客户端手里的是一份**被腰斩**的回答,必须补错误帧让它可识别
|
|
299
|
+
//(BUG-17:300s 腰斩 416KB 输出,旧行为只补 [DONE] 并按 200 返回,errors.log 零记录)。
|
|
300
|
+
if (endTruncatedStream(res, detail, flushHeldTail)) {
|
|
301
|
+
return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, truncated: true, preflightMs: preflight, detail };
|
|
302
|
+
}
|
|
303
|
+
flushHeldTail(); // 断流收场也要把 [DONE] 按序补上,别让客户端等一个不存在的终止符
|
|
304
|
+
try { res.end(); } catch { /* ignore */ }
|
|
305
|
+
return { status: 200, timedOut: false, ttfMs: ttf, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted, preflightMs: preflight, detail };
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
detail.exitReason = "empty-body";
|
|
309
|
+
}
|
|
310
|
+
const totalMs = Math.round(performance.now() - t0);
|
|
311
|
+
if (!detail.exitReason) detail.exitReason = "normal";
|
|
312
|
+
finishedNormally = true;
|
|
313
|
+
// 留口期间不摘 close 监听(close 只发一次,摘了客户端窗口内断开就永久盲窗、照烧额度)
|
|
314
|
+
const heldOpen = holdEndEnabled() && detail.wrotePayload !== true && !detail.downstreamClosed && isEmptyTurnDetail(detail);
|
|
315
|
+
if (!heldOpen) res.removeListener("close", onClose);
|
|
316
|
+
let terminalForm = null;
|
|
317
|
+
if (heldOpen) {
|
|
318
|
+
detail.exitReason = "empty-turn-hold";
|
|
319
|
+
} else {
|
|
320
|
+
if (!detail.downstreamClosed) {
|
|
321
|
+
commitPendingPrefix(); // 不留口 = 补写暂扣前缀(HOLD_END=0 时逐字节复现改前行为)
|
|
322
|
+
// 思考刷满 max_tokens、正文为零(现网主流):思考撤不回,错误帧必须排在 [DONE] 之前才看得见
|
|
323
|
+
const reasoningOnly = Number(detail.chars) === 0 && Number(detail.toolCalls) === 0 && Number(detail.reasoningChars) > 0;
|
|
324
|
+
if (reasoningOnly && holdEndEnabled()) {
|
|
325
|
+
detail.exitReason = "reasoning-only";
|
|
326
|
+
terminalForm = "sse-error-tail";
|
|
327
|
+
endEmptyTurnStream(res, `EMPTY_MODEL_RESPONSE: 只产出思考 ${detail.reasoningChars} 字、正文为零(额度被思考吃满),已重试仍无正文 — raise max_tokens or rephrase`, () => flushHeldTail());
|
|
328
|
+
} else {
|
|
329
|
+
flushHeldTail(); // 没有错误帧要插队,[DONE] 按原序发出
|
|
330
|
+
try { res.end(); } catch { /* ignore */ }
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
try { res.end(); } catch { /* ignore */ }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { status: 200, timedOut: false, ttfMs: ttf, totalMs, aborted: false, interrupted: false, heldOpen, terminalForm, preflightMs: preflight, detail };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
finishedNormally = true;
|
|
340
|
+
res.removeListener("close", onClose);
|
|
341
|
+
const text = await upRes.text();
|
|
342
|
+
detail.receivedBytes = Buffer.byteLength(text);
|
|
343
|
+
detail.exitReason = "normal-non-stream";
|
|
344
|
+
// 非流式同样「先判后写」:此刻 headers 还没 flush,body 一写下去就再无退路(判据实现见 stream-body.js)。
|
|
345
|
+
const ns = scanNonStreamReply(detail, text, { fallback, holdEnd: holdEndEnabled, isEmptyTurn: isEmptyTurnDetail, captureFallback: captureTalkFallback });
|
|
346
|
+
const enriched = ns.enriched;
|
|
347
|
+
if (ns.emptyTurn) {
|
|
348
|
+
detail.exitReason = "empty-turn-hold";
|
|
349
|
+
return { status: upRes.status, timedOut: false, ttfMs: null, totalMs: Math.round(performance.now() - t0), aborted: false, interrupted: false, heldOpen: true, preflightMs: preflight, detail };
|
|
350
|
+
}
|
|
351
|
+
detail.wrotePayload = true; // 真写下去了:从此撤不回,也不再判空轮
|
|
352
|
+
const nsTotalMs = Math.round(performance.now() - t0);
|
|
353
|
+
// 非流路径同样透判决头(429+Retry-After 此前只在这条路上丢过——真机 curl 实测)
|
|
354
|
+
if (!res.headersSent) propagateDecisionHeaders(res, upRes);
|
|
355
|
+
if (enriched !== null) {
|
|
356
|
+
json(res, upRes.status, enriched);
|
|
357
|
+
} else {
|
|
358
|
+
res.statusCode = upRes.status;
|
|
359
|
+
res.setHeader("Content-Type", contentType || "text/plain");
|
|
360
|
+
res.end(text);
|
|
361
|
+
}
|
|
362
|
+
// 非流式 ttf 视为 total(一次性返回)
|
|
363
|
+
return { status: upRes.status, timedOut: false, ttfMs: nsTotalMs, totalMs: nsTotalMs, aborted: false, interrupted: false, heldOpen: false, preflightMs: preflight, detail };
|
|
364
|
+
}
|