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