dsh-router-core 0.3.16 → 0.3.19
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/lib/client-registry.js +6 -4
- package/lib/client.js +6 -4
- package/lib/index.js +225 -104
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/router/usage-tokens.d.ts +8 -1
- package/package.json +1 -1
- package/src/client/StatsTab.tsx +16 -2
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +66 -9
- package/src/router/disconnect-leak.test.ts +200 -0
- package/src/router/index.ts +65 -9
- package/src/router/usage-tokens.ts +26 -5
- package/src/router/usage.test.ts +17 -0
package/lib/client-registry.js
CHANGED
|
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
|
|
|
3201
3201
|
}, [period, active]);
|
|
3202
3202
|
const s = stats;
|
|
3203
3203
|
const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
|
|
3204
|
+
const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
|
|
3204
3205
|
const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
|
|
3205
3206
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3206
3207
|
className: "dshr-tabBody",
|
|
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
|
|
|
3276
3277
|
tone: "ok"
|
|
3277
3278
|
}),
|
|
3278
3279
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
|
|
3279
|
-
label: "
|
|
3280
|
-
value:
|
|
3281
|
-
hint: "
|
|
3282
|
-
tone: "default"
|
|
3280
|
+
label: "缓存命中率",
|
|
3281
|
+
value: cacheRatio === null ? "—" : `${cacheRatio}%`,
|
|
3282
|
+
hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
|
|
3283
|
+
tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
|
|
3284
|
+
title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
|
|
3283
3285
|
}),
|
|
3284
3286
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
|
|
3285
3287
|
label: "平均耗时",
|
package/lib/client.js
CHANGED
|
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
|
|
|
3201
3201
|
}, [period, active]);
|
|
3202
3202
|
const s = stats;
|
|
3203
3203
|
const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
|
|
3204
|
+
const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
|
|
3204
3205
|
const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
|
|
3205
3206
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3206
3207
|
className: "dshr-tabBody",
|
|
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
|
|
|
3276
3277
|
tone: "ok"
|
|
3277
3278
|
}),
|
|
3278
3279
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
|
|
3279
|
-
label: "
|
|
3280
|
-
value:
|
|
3281
|
-
hint: "
|
|
3282
|
-
tone: "default"
|
|
3280
|
+
label: "缓存命中率",
|
|
3281
|
+
value: cacheRatio === null ? "—" : `${cacheRatio}%`,
|
|
3282
|
+
hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
|
|
3283
|
+
tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
|
|
3284
|
+
title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
|
|
3283
3285
|
}),
|
|
3284
3286
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
|
|
3285
3287
|
label: "平均耗时",
|
package/lib/index.js
CHANGED
|
@@ -1052,7 +1052,14 @@ function num(v) {
|
|
|
1052
1052
|
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
1053
1053
|
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
1054
1054
|
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
1055
|
-
*
|
|
1055
|
+
*
|
|
1056
|
+
* 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
|
|
1057
|
+
* Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
|
|
1058
|
+
* 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
|
|
1059
|
+
* 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
|
|
1060
|
+
* 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
|
|
1061
|
+
* 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
|
|
1062
|
+
* 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
|
|
1056
1063
|
*
|
|
1057
1064
|
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
1058
1065
|
*/
|
|
@@ -1061,7 +1068,7 @@ function normalizeUsage(raw) {
|
|
|
1061
1068
|
const u = raw;
|
|
1062
1069
|
const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
|
|
1063
1070
|
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
|
|
1064
|
-
if (u.cache_read_input_tokens !== void 0) prompt += num(u.cache_read_input_tokens);
|
|
1071
|
+
if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num(u.cache_read_input_tokens);
|
|
1065
1072
|
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
|
|
1066
1073
|
if (prompt === 0 && completion === 0 && cached === 0) return null;
|
|
1067
1074
|
return {
|
|
@@ -1228,29 +1235,43 @@ function tapStreamUsage(source, startedAt) {
|
|
|
1228
1235
|
outputChars += frameText(obj).length;
|
|
1229
1236
|
}
|
|
1230
1237
|
};
|
|
1238
|
+
/** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
1239
|
+
let reader;
|
|
1231
1240
|
return {
|
|
1232
|
-
stream: new ReadableStream({
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1241
|
+
stream: new ReadableStream({
|
|
1242
|
+
start: async (ctrl) => {
|
|
1243
|
+
reader = source.getReader();
|
|
1244
|
+
try {
|
|
1245
|
+
for (;;) {
|
|
1246
|
+
const { done, value } = await reader.read();
|
|
1247
|
+
if (done) break;
|
|
1248
|
+
if (firstAt === 0) firstAt = Date.now();
|
|
1249
|
+
buf += dec.decode(value, { stream: true });
|
|
1250
|
+
let sep;
|
|
1251
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1252
|
+
const frame = buf.slice(0, sep + 2);
|
|
1253
|
+
buf = buf.slice(sep + 2);
|
|
1254
|
+
parseFrame(frame);
|
|
1255
|
+
}
|
|
1256
|
+
ctrl.enqueue(value);
|
|
1245
1257
|
}
|
|
1246
|
-
|
|
1258
|
+
if (buf !== "") parseFrame(buf);
|
|
1259
|
+
} catch {} finally {
|
|
1260
|
+
try {
|
|
1261
|
+
reader.releaseLock();
|
|
1262
|
+
} catch {}
|
|
1247
1263
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1264
|
+
try {
|
|
1265
|
+
ctrl.close();
|
|
1266
|
+
} catch {}
|
|
1267
|
+
},
|
|
1268
|
+
/**
|
|
1269
|
+
* 取消要往下传(同 normalizeSSEStream 的理由):不传 → 客户端断开时
|
|
1270
|
+
* 上游 fetch body 永不 cancel,连接与 in-flight 请求一直挂着。
|
|
1271
|
+
* 必须用 reader.cancel():流已被 getReader() 锁住,stream.cancel() 会抛。
|
|
1272
|
+
*/
|
|
1273
|
+
cancel: (reason) => reader?.cancel(reason)
|
|
1274
|
+
}),
|
|
1254
1275
|
done: async () => ({
|
|
1255
1276
|
usage: merged,
|
|
1256
1277
|
outputChars,
|
|
@@ -1392,59 +1413,65 @@ async function aggregateSSE(stream) {
|
|
|
1392
1413
|
const calls = /* @__PURE__ */ new Map();
|
|
1393
1414
|
const reader = stream.getReader();
|
|
1394
1415
|
let buf = "";
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
if (
|
|
1425
|
-
|
|
1416
|
+
try {
|
|
1417
|
+
for (;;) {
|
|
1418
|
+
const { done, value } = await reader.read();
|
|
1419
|
+
if (done) break;
|
|
1420
|
+
buf += dec.decode(value, { stream: true });
|
|
1421
|
+
let sep;
|
|
1422
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1423
|
+
const frame = buf.slice(0, sep);
|
|
1424
|
+
buf = buf.slice(sep + 2);
|
|
1425
|
+
for (const line of frame.split("\n")) {
|
|
1426
|
+
if (!line.startsWith("data:")) continue;
|
|
1427
|
+
const payload = line.slice(5).trim();
|
|
1428
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
1429
|
+
let obj;
|
|
1430
|
+
try {
|
|
1431
|
+
obj = JSON.parse(payload);
|
|
1432
|
+
} catch {
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
|
|
1436
|
+
if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
|
|
1437
|
+
if (typeof obj.created === "number") created = obj.created;
|
|
1438
|
+
if (obj.usage !== void 0) usage = obj.usage;
|
|
1439
|
+
const ch = obj.choices?.[0];
|
|
1440
|
+
if (ch === void 0) continue;
|
|
1441
|
+
if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
|
|
1442
|
+
const delta = ch.delta;
|
|
1443
|
+
if (delta === void 0) {
|
|
1444
|
+
const msg = ch.message;
|
|
1445
|
+
if (msg !== void 0) {
|
|
1446
|
+
if (typeof msg.content === "string") content += msg.content;
|
|
1447
|
+
if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
|
|
1448
|
+
}
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
if (typeof delta.content === "string") content += delta.content;
|
|
1452
|
+
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
1453
|
+
const tcs = delta.tool_calls;
|
|
1454
|
+
if (!Array.isArray(tcs)) continue;
|
|
1455
|
+
for (const tc of tcs) {
|
|
1456
|
+
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
1457
|
+
const fn = tc.function;
|
|
1458
|
+
const prev = calls.get(idx) ?? {
|
|
1459
|
+
name: "",
|
|
1460
|
+
args: ""
|
|
1461
|
+
};
|
|
1462
|
+
if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
|
|
1463
|
+
if (typeof tc.type === "string") prev.type = tc.type;
|
|
1464
|
+
if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
|
|
1465
|
+
if (typeof fn?.arguments === "string") prev.args += fn.arguments;
|
|
1466
|
+
calls.set(idx, prev);
|
|
1426
1467
|
}
|
|
1427
|
-
continue;
|
|
1428
|
-
}
|
|
1429
|
-
if (typeof delta.content === "string") content += delta.content;
|
|
1430
|
-
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
1431
|
-
const tcs = delta.tool_calls;
|
|
1432
|
-
if (!Array.isArray(tcs)) continue;
|
|
1433
|
-
for (const tc of tcs) {
|
|
1434
|
-
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
1435
|
-
const fn = tc.function;
|
|
1436
|
-
const prev = calls.get(idx) ?? {
|
|
1437
|
-
name: "",
|
|
1438
|
-
args: ""
|
|
1439
|
-
};
|
|
1440
|
-
if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
|
|
1441
|
-
if (typeof tc.type === "string") prev.type = tc.type;
|
|
1442
|
-
if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
|
|
1443
|
-
if (typeof fn?.arguments === "string") prev.args += fn.arguments;
|
|
1444
|
-
calls.set(idx, prev);
|
|
1445
1468
|
}
|
|
1446
1469
|
}
|
|
1447
1470
|
}
|
|
1471
|
+
} finally {
|
|
1472
|
+
try {
|
|
1473
|
+
reader.releaseLock();
|
|
1474
|
+
} catch {}
|
|
1448
1475
|
}
|
|
1449
1476
|
const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([, c]) => ({
|
|
1450
1477
|
id: c.id ?? `call_${c.name}`,
|
|
@@ -1498,31 +1525,47 @@ function normalizeSSEStream(stream) {
|
|
|
1498
1525
|
const dec = new TextDecoder();
|
|
1499
1526
|
let buf = "";
|
|
1500
1527
|
let sawDone = false;
|
|
1528
|
+
/** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
1529
|
+
let reader;
|
|
1501
1530
|
return {
|
|
1502
|
-
stream: new ReadableStream({
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1531
|
+
stream: new ReadableStream({
|
|
1532
|
+
start: async (ctrl) => {
|
|
1533
|
+
reader = stream.getReader();
|
|
1534
|
+
try {
|
|
1535
|
+
for (;;) {
|
|
1536
|
+
const { done, value } = await reader.read();
|
|
1537
|
+
if (done) break;
|
|
1538
|
+
buf += dec.decode(value, { stream: true });
|
|
1539
|
+
let sep;
|
|
1540
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
1541
|
+
const frame = buf.slice(0, sep + 2);
|
|
1542
|
+
buf = buf.slice(sep + 2);
|
|
1543
|
+
if (isDoneFrame(frame)) sawDone = true;
|
|
1544
|
+
ctrl.enqueue(enc.encode(fixFrame(frame)));
|
|
1545
|
+
}
|
|
1515
1546
|
}
|
|
1547
|
+
if (buf !== "") {
|
|
1548
|
+
if (isDoneFrame(buf)) sawDone = true;
|
|
1549
|
+
ctrl.enqueue(enc.encode(fixFrame(buf)));
|
|
1550
|
+
}
|
|
1551
|
+
} catch {} finally {
|
|
1552
|
+
try {
|
|
1553
|
+
reader.releaseLock();
|
|
1554
|
+
} catch {}
|
|
1516
1555
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1556
|
+
try {
|
|
1557
|
+
ctrl.close();
|
|
1558
|
+
} catch {}
|
|
1559
|
+
},
|
|
1560
|
+
/**
|
|
1561
|
+
* **取消必须往下传**:ReadableStream 的取消不会自动穿过 wrapper。
|
|
1562
|
+
* 不实现这个分支,客户端断开的取消就停在这里,上游 fetch body 永不 cancel
|
|
1563
|
+
* ——那条 TCP 连接和 in-flight 请求原样挂着(长跑一天就会累积到耗尽)。
|
|
1564
|
+
* 用 reader.cancel() 而不是 stream.cancel():流已被 getReader() 锁住,
|
|
1565
|
+
* 直接 cancel 会抛 `Invalid state: ReadableStream is locked`。
|
|
1566
|
+
*/
|
|
1567
|
+
cancel: (reason) => reader?.cancel(reason)
|
|
1568
|
+
}),
|
|
1526
1569
|
sawDone: () => sawDone
|
|
1527
1570
|
};
|
|
1528
1571
|
}
|
|
@@ -1592,6 +1635,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1592
1635
|
try {
|
|
1593
1636
|
body = await aggregateSSE(r.stream);
|
|
1594
1637
|
} catch {
|
|
1638
|
+
await r.stream.cancel().catch(() => {});
|
|
1595
1639
|
return false;
|
|
1596
1640
|
}
|
|
1597
1641
|
if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
|
|
@@ -1606,6 +1650,18 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1606
1650
|
let wroteAny = false;
|
|
1607
1651
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
1608
1652
|
let dead = false;
|
|
1653
|
+
/**
|
|
1654
|
+
* 唤醒「等 drain 的挂起 write」。客户端断开时必须调它:那时 'drain' 永不到来,
|
|
1655
|
+
* 不唤醒就永远卡在 write 里,pipeTo 不 settle,finally 不执行,
|
|
1656
|
+
* **上游流也就永不 cancel**(= 每次客户端放弃漏一条连接)。
|
|
1657
|
+
*/
|
|
1658
|
+
let releaseWait;
|
|
1659
|
+
/**
|
|
1660
|
+
* 中止 pipeTo 的信号源(客户端断开时 abort)。
|
|
1661
|
+
* 单靠 `dead` 标志只能让**下一次** write 立刻返回,唤醒不了**已经挂起**的那次;
|
|
1662
|
+
* abort 才能让 pipeTo 立刻收尾并走它的取消路径(进而 cancel 上游)。
|
|
1663
|
+
*/
|
|
1664
|
+
const abort = new AbortController();
|
|
1609
1665
|
const writeChunk = (chunk) => new Promise((resolve) => {
|
|
1610
1666
|
if (dead) {
|
|
1611
1667
|
resolve();
|
|
@@ -1628,6 +1684,11 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1628
1684
|
resolve();
|
|
1629
1685
|
};
|
|
1630
1686
|
res.once("drain", onDrain);
|
|
1687
|
+
/** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
|
|
1688
|
+
releaseWait = () => {
|
|
1689
|
+
res.removeListener("drain", onDrain);
|
|
1690
|
+
resolve();
|
|
1691
|
+
};
|
|
1631
1692
|
}
|
|
1632
1693
|
});
|
|
1633
1694
|
const onError = () => {
|
|
@@ -1635,13 +1696,28 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1635
1696
|
res.destroy();
|
|
1636
1697
|
};
|
|
1637
1698
|
res.once("error", onError);
|
|
1699
|
+
/**
|
|
1700
|
+
* **客户端断开必须被当成取消信号**:只监听 'error' 是不够的 —— 对端关闭
|
|
1701
|
+
* (pi-ai 的流空闲超时、用户取消、消费方提前退出)触发的是 'close',
|
|
1702
|
+
* 不是 'error'。此时若不收手,pipeTo 会一直等着 drain(永不到来),
|
|
1703
|
+
* finally 永不执行,上游 fetch body **永不 cancel** → 那条 TCP 连接和
|
|
1704
|
+
* in-flight 请求原样挂着,长跑累积到耗尽("用久了就报 connection error")。
|
|
1705
|
+
*/
|
|
1706
|
+
const onClose = () => {
|
|
1707
|
+
if (res.writableEnded) return;
|
|
1708
|
+
dead = true;
|
|
1709
|
+
releaseWait?.();
|
|
1710
|
+
abort.abort();
|
|
1711
|
+
};
|
|
1712
|
+
res.once("close", onClose);
|
|
1638
1713
|
const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
|
|
1639
1714
|
try {
|
|
1640
|
-
await norm.stream.pipeTo(new WritableStream({ write: writeChunk }));
|
|
1715
|
+
await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
|
|
1641
1716
|
} catch {} finally {
|
|
1642
1717
|
res.removeListener("error", onError);
|
|
1718
|
+
res.removeListener("close", onClose);
|
|
1643
1719
|
if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) await writeChunk(DONE_FRAME);
|
|
1644
|
-
if (wroteAny) res.end();
|
|
1720
|
+
if (wroteAny && !dead) res.end();
|
|
1645
1721
|
if (probe !== void 0 && tapped !== void 0) {
|
|
1646
1722
|
const got = await tapped.done();
|
|
1647
1723
|
probe.tokens = got.usage;
|
|
@@ -2308,6 +2384,16 @@ var Router = class {
|
|
|
2308
2384
|
*/
|
|
2309
2385
|
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
2310
2386
|
const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
2387
|
+
/**
|
|
2388
|
+
* 流空闲超时:上游连上后这么久没吐**任何**字节就判失败(抛 TIMEOUT,可重试)。
|
|
2389
|
+
*
|
|
2390
|
+
* 对齐 `dsh-llm-pi-ai` 的 `DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000`:同一个
|
|
2391
|
+
* dsh-router `/v1`,走自定义供应商(pi-ai)时有这个兜底、走内置 adapter 时没有
|
|
2392
|
+
* —— 差异导致「上游停摆」在内置路径上永久挂住。
|
|
2393
|
+
*
|
|
2394
|
+
* 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
|
|
2395
|
+
*/
|
|
2396
|
+
const STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
2311
2397
|
async function wireMessages(options, system, attachments) {
|
|
2312
2398
|
const out = [];
|
|
2313
2399
|
/**
|
|
@@ -2749,11 +2835,18 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2749
2835
|
baseURL;
|
|
2750
2836
|
source;
|
|
2751
2837
|
resolveAttachments;
|
|
2752
|
-
|
|
2838
|
+
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
2839
|
+
idleTimeoutMs;
|
|
2840
|
+
/**
|
|
2841
|
+
* @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
|
|
2842
|
+
* 可注入只为测试能压到几百毫秒,生产不传。
|
|
2843
|
+
*/
|
|
2844
|
+
constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
|
|
2753
2845
|
super();
|
|
2754
2846
|
this.baseURL = baseURL;
|
|
2755
2847
|
this.source = source;
|
|
2756
2848
|
this.resolveAttachments = resolveAttachments ?? (() => void 0);
|
|
2849
|
+
this.idleTimeoutMs = idleTimeoutMs;
|
|
2757
2850
|
}
|
|
2758
2851
|
providerInfo(provider) {
|
|
2759
2852
|
return {
|
|
@@ -2817,16 +2910,19 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2817
2910
|
signal: controller.signal
|
|
2818
2911
|
});
|
|
2819
2912
|
} catch (error) {
|
|
2913
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
2820
2914
|
throw new LlmError(`dsh-router upstream call failed: ${error.message}`, "TRANSPORT", { cause: error });
|
|
2915
|
+
}
|
|
2916
|
+
try {
|
|
2917
|
+
if (!resp.ok) {
|
|
2918
|
+
const detail = await resp.text().catch(() => "");
|
|
2919
|
+
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
|
|
2920
|
+
}
|
|
2921
|
+
if (resp.body === null) return;
|
|
2922
|
+
yield* translateSse(ssePayloads(resp.body, this.idleTimeoutMs));
|
|
2821
2923
|
} finally {
|
|
2822
2924
|
options.signal?.removeEventListener("abort", onAbort);
|
|
2823
2925
|
}
|
|
2824
|
-
if (!resp.ok) {
|
|
2825
|
-
const detail = await resp.text().catch(() => "");
|
|
2826
|
-
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
|
|
2827
|
-
}
|
|
2828
|
-
if (resp.body === null) return;
|
|
2829
|
-
yield* translateSse(ssePayloads(resp.body));
|
|
2830
2926
|
}
|
|
2831
2927
|
};
|
|
2832
2928
|
/**
|
|
@@ -2854,16 +2950,41 @@ function looksComplete(payload) {
|
|
|
2854
2950
|
* 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
|
|
2855
2951
|
* 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
|
|
2856
2952
|
*/
|
|
2857
|
-
async function* ssePayloads(body) {
|
|
2953
|
+
async function* ssePayloads(body, idleTimeoutMs) {
|
|
2858
2954
|
const reader = body.getReader();
|
|
2859
2955
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
2860
2956
|
let buffer = "";
|
|
2861
2957
|
/** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
|
|
2862
2958
|
* 状态 —— 放模块级会让并发请求互相串数据。 */
|
|
2863
2959
|
let payload = "";
|
|
2960
|
+
/**
|
|
2961
|
+
* 带**空闲超时**的读:上游连上后若长时间一个字节都不吐,就读穿不了 ——
|
|
2962
|
+
* 没有超时的话这里会永久挂着,连接和 in-flight 请求都收不回。
|
|
2963
|
+
*
|
|
2964
|
+
* 为什么必须有(issue #6 的另一半):`dsh-llm-pi-ai`(自定义供应商走的那条
|
|
2965
|
+
* openai-completions adapter)有 `idleWatchdog`,默认 **300s** 空闲即
|
|
2966
|
+
* `LLM_STREAM_IDLE_TIMEOUT` 中止;而本 adapter 此前**没有任何超时**,只靠
|
|
2967
|
+
* 调用方的 `options.signal`。于是同一个组合,走内置 Router 时上游一停摆就
|
|
2968
|
+
* 永久挂住(占用连接、且该请求永不收尾),走自定义供应商时 300s 后被放弃
|
|
2969
|
+
* —— 放弃又会触发服务端那条「客户端断开不回收」的泄漏路径,两边叠加就是
|
|
2970
|
+
* 「用久了报 connection error」。
|
|
2971
|
+
*
|
|
2972
|
+
* 只守**空闲**、不设总时长:长生成可以跑很久,只要还在吐块就不该被打断
|
|
2973
|
+
* (总时长封顶会让长回答写到一半被砍)。
|
|
2974
|
+
*/
|
|
2975
|
+
const readWithIdleTimeout = () => new Promise((resolve, reject) => {
|
|
2976
|
+
const timer = setTimeout(() => reject(new LlmError(`dsh-router upstream stream idle for ${idleTimeoutMs}ms`, "TIMEOUT")), idleTimeoutMs);
|
|
2977
|
+
reader.read().then((r) => {
|
|
2978
|
+
clearTimeout(timer);
|
|
2979
|
+
resolve(r);
|
|
2980
|
+
}, (e) => {
|
|
2981
|
+
clearTimeout(timer);
|
|
2982
|
+
reject(e);
|
|
2983
|
+
});
|
|
2984
|
+
});
|
|
2864
2985
|
try {
|
|
2865
2986
|
for (;;) {
|
|
2866
|
-
const { done, value } = await
|
|
2987
|
+
const { done, value } = await readWithIdleTimeout();
|
|
2867
2988
|
if (done) break;
|
|
2868
2989
|
buffer += decoder.decode(value, { stream: true });
|
|
2869
2990
|
const lines = buffer.split("\n");
|
|
@@ -58,7 +58,13 @@ export declare class RouterAdapter extends LlmAdapter {
|
|
|
58
58
|
private readonly baseURL;
|
|
59
59
|
private readonly source;
|
|
60
60
|
private readonly resolveAttachments;
|
|
61
|
-
|
|
61
|
+
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
62
|
+
private readonly idleTimeoutMs;
|
|
63
|
+
/**
|
|
64
|
+
* @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
|
|
65
|
+
* 可注入只为测试能压到几百毫秒,生产不传。
|
|
66
|
+
*/
|
|
67
|
+
constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined, idleTimeoutMs?: number);
|
|
62
68
|
providerInfo(provider: string): {
|
|
63
69
|
id: string;
|
|
64
70
|
name: string;
|
|
@@ -38,7 +38,14 @@ export interface Usage extends UsageTokens {
|
|
|
38
38
|
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
39
39
|
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
40
40
|
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
41
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
|
|
43
|
+
* Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
|
|
44
|
+
* 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
|
|
45
|
+
* 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
|
|
46
|
+
* 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
|
|
47
|
+
* 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
|
|
48
|
+
* 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
|
|
42
49
|
*
|
|
43
50
|
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
44
51
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.19",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/client/StatsTab.tsx
CHANGED
|
@@ -421,6 +421,12 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
|
|
|
421
421
|
const successRate = s !== null && s.requests !== undefined && s.requests > 0
|
|
422
422
|
? Math.round(((s.okCount ?? 0) / s.requests) * 100)
|
|
423
423
|
: null
|
|
424
|
+
// 缓存命中率 = cachedTokens / promptTokens(cachedTokens 是 promptTokens
|
|
425
|
+
// 的子集:命中缓存的输入部分)。分母 0 = 本周期没有带缓存语义的请求,
|
|
426
|
+
// 显示 — 而不是硬造 0%(那会把「没数据」和「全未命中」混为一谈)。
|
|
427
|
+
const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0
|
|
428
|
+
? Math.round(((s.cachedTokens ?? 0) / (s.promptTokens ?? 0)) * 100)
|
|
429
|
+
: null
|
|
424
430
|
// 估算提示:只有真有估算时才显示,别没事吓唬人
|
|
425
431
|
const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0)
|
|
426
432
|
|
|
@@ -490,8 +496,16 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
|
|
|
490
496
|
hint={(s?.estimatedOutputs ?? 0) > 0 ? '~ 含估算' : undefined}
|
|
491
497
|
tone="ok"
|
|
492
498
|
/>
|
|
493
|
-
{/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token
|
|
494
|
-
|
|
499
|
+
{/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token。
|
|
500
|
+
显示命中率而不是 token 数:50.2% 这种数一眼能读出「缓存工作是否正常」,
|
|
501
|
+
绝对值要看的话输入 Tokens 卡里已含(title 给出原始数字)。 */}
|
|
502
|
+
<StatCard
|
|
503
|
+
label="缓存命中率"
|
|
504
|
+
value={cacheRatio === null ? '—' : `${cacheRatio}%`}
|
|
505
|
+
hint={cacheRatio === null ? '命中缓存的输入占比' : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`}
|
|
506
|
+
tone={cacheRatio !== null && cacheRatio >= 80 ? 'ok' : 'default'}
|
|
507
|
+
title="缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
|
|
508
|
+
/>
|
|
495
509
|
<StatCard
|
|
496
510
|
label="平均耗时"
|
|
497
511
|
value={fmtDuration(s?.avgDurationMs)}
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -15,6 +15,13 @@ import { EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
|
15
15
|
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
16
16
|
import { RouterAdapter, translateSse } from './adapter.ts'
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* 本文件大量用例把 `globalThis.fetch` 打桩成假上游,且**从不还原**(沿既有惯例:
|
|
20
|
+
* 每个用例自己设桩)。所以「要打真实网络」的用例必须自己把真 fetch 放回去 ——
|
|
21
|
+
* 否则会静默拿到上一个用例残留的桩(曾导致「停摆测试 0ms 就 ended」的假结果)。
|
|
22
|
+
*/
|
|
23
|
+
const realFetch: typeof fetch = globalThis.fetch
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* dsh-llm 默认可重试的错误码白名单(DEFAULT_RETRYABLE_CODES)。
|
|
20
27
|
*
|
|
@@ -727,3 +734,78 @@ test('wire:纯文本 tool-result 不因图片逻辑改变顺序(tool 紧跟
|
|
|
727
734
|
assertToolPairingIntact(messages)
|
|
728
735
|
assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 结果仍紧跟 assistant')
|
|
729
736
|
})
|
|
737
|
+
|
|
738
|
+
/* ---------------- 流空闲超时:上游停摆不能永久挂住(issue #6) ---------------- */
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* 为什么要有这一组:同一个 dsh-router `/v1`,走**自定义供应商**(llm-pi-ai 的
|
|
742
|
+
* openai-completions adapter)时有 `idleWatchdog`(默认 300s)兜底,走**内置
|
|
743
|
+
* Router adapter** 时此前没有任何超时 —— 上游连上后一停摆就永久挂着,连接和
|
|
744
|
+
* in-flight 请求都收不回。这是 issue #6「内置 vs 自定义」差异的一半。
|
|
745
|
+
*
|
|
746
|
+
* **必须用真实 http server**:合成 `ReadableStream` 上 `AbortSignal` 是无效的
|
|
747
|
+
* (实测合成流 + AbortSignal.timeout 永不收尾),拿它测只能得到假结论。
|
|
748
|
+
*/
|
|
749
|
+
test('流空闲超时:上游停摆 → adapter 自己中止(不依赖调用方给 signal)', async () => {
|
|
750
|
+
const http = await import('node:http')
|
|
751
|
+
const server = http.createServer((_req, res) => {
|
|
752
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
753
|
+
res.flushHeaders() // 一个字节都不吐,也不结束
|
|
754
|
+
})
|
|
755
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
756
|
+
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`
|
|
757
|
+
globalThis.fetch = realFetch // 本文件前面的用例会留桩,必须装回真 fetch
|
|
758
|
+
try {
|
|
759
|
+
// 用可注入的短超时(默认 300s,测起来太慢);不注入就退回生产值。
|
|
760
|
+
const adapter = new RouterAdapter(base, { comboModels: async () => [] }, undefined, 400)
|
|
761
|
+
const started = Date.now()
|
|
762
|
+
const consume = (async () => {
|
|
763
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [] } as never)) void _c
|
|
764
|
+
})()
|
|
765
|
+
const outcome = await Promise.race([
|
|
766
|
+
consume.then(() => 'ended', (e: Error) => `threw:${(e as Error & { code?: string }).code ?? e.message}`),
|
|
767
|
+
new Promise<string>((r) => setTimeout(() => r('HANG'), 4000)),
|
|
768
|
+
])
|
|
769
|
+
const ms = Date.now() - started
|
|
770
|
+
console.log(`[gate] 无调用方 signal,停摆 → ${outcome} @${ms}ms`)
|
|
771
|
+
assert.notEqual(outcome, 'HANG', 'adapter 必须自带空闲超时,不能永久挂住')
|
|
772
|
+
assert.ok(String(outcome).includes('TIMEOUT'), `应抛 TIMEOUT(可重试码),实际 ${outcome}`)
|
|
773
|
+
} finally {
|
|
774
|
+
server.closeAllConnections?.()
|
|
775
|
+
server.close()
|
|
776
|
+
}
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
test('调用方取消必须能贯穿整条流(响应头到手后仍可中断,不永久挂住)', async () => {
|
|
780
|
+
// 用**真实 http server**:合成 ReadableStream 会给出假象(abort 对非 undici 流
|
|
781
|
+
// 无效)。这里上游发出响应头 + 一块数据后停摆,调用方给 1s 超时。
|
|
782
|
+
// 修复前:adapter 在 fetch 返回时就 removeEventListener,body 读无人打断 →
|
|
783
|
+
// 请求永久挂住(实测 6s 仍未收尾)。修复后:1s 左右被中止。
|
|
784
|
+
const http = await import('node:http')
|
|
785
|
+
const server = http.createServer((_req, res) => {
|
|
786
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
|
|
787
|
+
res.write('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')
|
|
788
|
+
res.flushHeaders() // 之后停摆:不吐数据也不结束
|
|
789
|
+
})
|
|
790
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
791
|
+
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`
|
|
792
|
+
globalThis.fetch = realFetch // 同上:必须先装回真 fetch
|
|
793
|
+
try {
|
|
794
|
+
const adapter = new RouterAdapter(base, { comboModels: async () => [] })
|
|
795
|
+
const t0 = Date.now()
|
|
796
|
+
const consume = (async () => {
|
|
797
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(1000) } as never)) void _c
|
|
798
|
+
})()
|
|
799
|
+
const outcome = await Promise.race([
|
|
800
|
+
consume.then(() => 'ended', (e: Error) => `threw:${e.message}`),
|
|
801
|
+
new Promise((r) => setTimeout(() => r('HANG'), 5000)),
|
|
802
|
+
])
|
|
803
|
+
const ms = Date.now() - t0
|
|
804
|
+
console.log(`[gate] 停摆 + 1s 超时 → ${outcome} @${ms}ms`)
|
|
805
|
+
assert.notEqual(outcome, 'HANG', '调用方取消后必须收尾,不能永久挂住')
|
|
806
|
+
assert.ok(ms < 4000, `应在调用方超时附近收尾(实际 ${ms}ms)`)
|
|
807
|
+
} finally {
|
|
808
|
+
server.closeAllConnections?.()
|
|
809
|
+
server.close()
|
|
810
|
+
}
|
|
811
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -81,6 +81,17 @@ export interface RouterAdapterSource {
|
|
|
81
81
|
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
82
82
|
const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* 流空闲超时:上游连上后这么久没吐**任何**字节就判失败(抛 TIMEOUT,可重试)。
|
|
86
|
+
*
|
|
87
|
+
* 对齐 `dsh-llm-pi-ai` 的 `DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000`:同一个
|
|
88
|
+
* dsh-router `/v1`,走自定义供应商(pi-ai)时有这个兜底、走内置 adapter 时没有
|
|
89
|
+
* —— 差异导致「上游停摆」在内置路径上永久挂住。
|
|
90
|
+
*
|
|
91
|
+
* 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
|
|
92
|
+
*/
|
|
93
|
+
const STREAM_IDLE_TIMEOUT_MS = 300_000
|
|
94
|
+
|
|
84
95
|
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
|
|
85
96
|
const out: Array<Record<string, unknown>> = []
|
|
86
97
|
/**
|
|
@@ -530,12 +541,24 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
530
541
|
private readonly baseURL: string
|
|
531
542
|
private readonly source: RouterAdapterSource
|
|
532
543
|
private readonly resolveAttachments: () => RouterAttachmentStore | undefined
|
|
544
|
+
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
545
|
+
private readonly idleTimeoutMs: number
|
|
533
546
|
|
|
534
|
-
|
|
547
|
+
/**
|
|
548
|
+
* @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
|
|
549
|
+
* 可注入只为测试能压到几百毫秒,生产不传。
|
|
550
|
+
*/
|
|
551
|
+
constructor(
|
|
552
|
+
baseURL: string,
|
|
553
|
+
source: RouterAdapterSource,
|
|
554
|
+
resolveAttachments?: () => RouterAttachmentStore | undefined,
|
|
555
|
+
idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
|
|
556
|
+
) {
|
|
535
557
|
super()
|
|
536
558
|
this.baseURL = baseURL
|
|
537
559
|
this.source = source
|
|
538
560
|
this.resolveAttachments = resolveAttachments ?? (() => undefined)
|
|
561
|
+
this.idleTimeoutMs = idleTimeoutMs
|
|
539
562
|
}
|
|
540
563
|
|
|
541
564
|
providerInfo(provider: string): { id: string; name: string } {
|
|
@@ -602,16 +625,24 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
602
625
|
signal: controller.signal,
|
|
603
626
|
})
|
|
604
627
|
} catch (error) {
|
|
628
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
605
629
|
throw new LlmError(`dsh-router upstream call failed: ${(error as Error).message}`, 'TRANSPORT', { cause: error })
|
|
630
|
+
}
|
|
631
|
+
// **监听必须活到整条流结束**,不能在 fetch 返回时就摘:`controller.signal`
|
|
632
|
+
// 已经把传入的 signal 与这次 fetch 绑在一起(含 body 流),响应头到手后
|
|
633
|
+
// body 还在读,此时调用方取消(用户中断 / agent-loop 收敛 / 上游停摆被
|
|
634
|
+
// 上层超时放弃)必须还能 abort 掉这次请求 —— 摘早了,body 读就无人打断,
|
|
635
|
+
// 请求永久挂住、连接也收不回(issue #6「内置供应商才挂死」的另一半)。
|
|
636
|
+
try {
|
|
637
|
+
if (!resp.ok) {
|
|
638
|
+
const detail = await resp.text().catch(() => '')
|
|
639
|
+
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
640
|
+
}
|
|
641
|
+
if (resp.body === null) return
|
|
642
|
+
yield* translateSse(ssePayloads(resp.body, this.idleTimeoutMs))
|
|
606
643
|
} finally {
|
|
607
644
|
options.signal?.removeEventListener('abort', onAbort)
|
|
608
645
|
}
|
|
609
|
-
if (!resp.ok) {
|
|
610
|
-
const detail = await resp.text().catch(() => '')
|
|
611
|
-
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
612
|
-
}
|
|
613
|
-
if (resp.body === null) return
|
|
614
|
-
yield* translateSse(ssePayloads(resp.body))
|
|
615
646
|
}
|
|
616
647
|
}
|
|
617
648
|
|
|
@@ -641,16 +672,42 @@ function looksComplete(payload: string): boolean {
|
|
|
641
672
|
* 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
|
|
642
673
|
* 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
|
|
643
674
|
*/
|
|
644
|
-
async function* ssePayloads(body: ReadableStream<Uint8Array
|
|
675
|
+
async function* ssePayloads(body: ReadableStream<Uint8Array>, idleTimeoutMs: number): AsyncGenerator<string> {
|
|
645
676
|
const reader = body.getReader()
|
|
646
677
|
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
647
678
|
let buffer = ''
|
|
648
679
|
/** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
|
|
649
680
|
* 状态 —— 放模块级会让并发请求互相串数据。 */
|
|
650
681
|
let payload = ''
|
|
682
|
+
/**
|
|
683
|
+
* 带**空闲超时**的读:上游连上后若长时间一个字节都不吐,就读穿不了 ——
|
|
684
|
+
* 没有超时的话这里会永久挂着,连接和 in-flight 请求都收不回。
|
|
685
|
+
*
|
|
686
|
+
* 为什么必须有(issue #6 的另一半):`dsh-llm-pi-ai`(自定义供应商走的那条
|
|
687
|
+
* openai-completions adapter)有 `idleWatchdog`,默认 **300s** 空闲即
|
|
688
|
+
* `LLM_STREAM_IDLE_TIMEOUT` 中止;而本 adapter 此前**没有任何超时**,只靠
|
|
689
|
+
* 调用方的 `options.signal`。于是同一个组合,走内置 Router 时上游一停摆就
|
|
690
|
+
* 永久挂住(占用连接、且该请求永不收尾),走自定义供应商时 300s 后被放弃
|
|
691
|
+
* —— 放弃又会触发服务端那条「客户端断开不回收」的泄漏路径,两边叠加就是
|
|
692
|
+
* 「用久了报 connection error」。
|
|
693
|
+
*
|
|
694
|
+
* 只守**空闲**、不设总时长:长生成可以跑很久,只要还在吐块就不该被打断
|
|
695
|
+
* (总时长封顶会让长回答写到一半被砍)。
|
|
696
|
+
*/
|
|
697
|
+
const readWithIdleTimeout = (): Promise<ReadableStreamReadResult<Uint8Array>> =>
|
|
698
|
+
new Promise((resolve, reject) => {
|
|
699
|
+
const timer = setTimeout(
|
|
700
|
+
() => reject(new LlmError(`dsh-router upstream stream idle for ${idleTimeoutMs}ms`, 'TIMEOUT')),
|
|
701
|
+
idleTimeoutMs,
|
|
702
|
+
)
|
|
703
|
+
reader.read().then(
|
|
704
|
+
(r) => { clearTimeout(timer); resolve(r) },
|
|
705
|
+
(e) => { clearTimeout(timer); reject(e) },
|
|
706
|
+
)
|
|
707
|
+
})
|
|
651
708
|
try {
|
|
652
709
|
for (;;) {
|
|
653
|
-
const { done, value } = await
|
|
710
|
+
const { done, value } = await readWithIdleTimeout()
|
|
654
711
|
if (done) break
|
|
655
712
|
buffer += decoder.decode(value, { stream: true })
|
|
656
713
|
const lines = buffer.split('\n')
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端断开的连接回收测试 —— issue #6「组合模型用久了报 connection error」的真根因。
|
|
3
|
+
*
|
|
4
|
+
* 现象:把 `http://127.0.0.1:3080/v1` 加为**自定义供应商**(llm-pi-ai 的
|
|
5
|
+
* openai-completions)后,连续用半天到一天就全面 connection error;内置 Router
|
|
6
|
+
* 供应商同一个组合却正常。
|
|
7
|
+
*
|
|
8
|
+
* 真根因(**不是**「undici 每 origin 限 10 socket」——那条实测不成立,见下):
|
|
9
|
+
* 客户端主动断开时,服务端 `writeChatResult` 只监听 `res` 的 `'error'`,**不监听
|
|
10
|
+
* `'close'`**。对端关闭触发的是 `'close'`,于是:
|
|
11
|
+
* - 上游流还在推 → `writeChunk` 里 `res.write` 返回 false → 挂起等 `'drain'`
|
|
12
|
+
* - 连接已死 → `'drain'` 永不到来 → `pipeTo` 永不 settle
|
|
13
|
+
* - → `finally` 不执行 → 上游 fetch body **永不 cancel**
|
|
14
|
+
* → 每次「客户端放弃」漏一条上游 TCP 连接 + 挂一个请求;长跑累积到 fd/端口耗尽。
|
|
15
|
+
*
|
|
16
|
+
* 为什么只有自定义供应商路径中招:pi-ai 有**流空闲超时**(idleWatchdog,默认
|
|
17
|
+
* 300s)和消费方提前退出时的 `consumer.abort()`,会**主动放弃**请求;而 dsh-router
|
|
18
|
+
* 自己的 adapter 不设任何超时、从不主动放弃 → 内置路径几乎不触发这条泄漏。
|
|
19
|
+
*
|
|
20
|
+
* 为什么「每 origin 10 socket」的解释不成立:实测(探针 I)连续泄漏 200 次
|
|
21
|
+
* **全部成功、无排队**(单次 ≤10ms),undici 会自行关闭多余连接 —— 默认 fetch
|
|
22
|
+
* 没有这个硬上限,所以「池满 → 全部 connection error」推不出来。
|
|
23
|
+
*
|
|
24
|
+
* 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
|
|
25
|
+
*/
|
|
26
|
+
import { test } from 'node:test'
|
|
27
|
+
import assert from 'node:assert/strict'
|
|
28
|
+
import http from 'node:http'
|
|
29
|
+
import { Router } from './index.ts'
|
|
30
|
+
import { AccountPool } from './account-pool.ts'
|
|
31
|
+
import type { ChatOnceResult, SupplierAccountNow, SupplierStatusNow } from '../suppliers/contract.ts'
|
|
32
|
+
import type { ModelInfo } from './types.ts'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 造一个**永不结束**的上游流(模拟长生成),并记录它有没有被 cancel。
|
|
36
|
+
* `enqueued` 用来证明「断开后服务端是否还在继续消费」。
|
|
37
|
+
*/
|
|
38
|
+
function infiniteStream(): { stream: ReadableStream<Uint8Array>; cancelled: () => boolean; enqueued: () => number } {
|
|
39
|
+
const enc = new TextEncoder()
|
|
40
|
+
let cancelled = false
|
|
41
|
+
let enqueued = 0
|
|
42
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
43
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
44
|
+
start(ctrl) {
|
|
45
|
+
timer = setInterval(() => {
|
|
46
|
+
try { ctrl.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"x"}}]}\n\n')); enqueued += 1 } catch { /* closed */ }
|
|
47
|
+
}, 20)
|
|
48
|
+
},
|
|
49
|
+
cancel() { cancelled = true; if (timer !== undefined) clearInterval(timer) },
|
|
50
|
+
})
|
|
51
|
+
return { stream, cancelled: () => cancelled, enqueued: () => enqueued }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function supplierWith(stream: ReadableStream<Uint8Array>) {
|
|
55
|
+
return {
|
|
56
|
+
id: 'sup', name: 'sup', priority: 0, pool: new AccountPool('sup'),
|
|
57
|
+
status: (): SupplierStatusNow => ({ id: 'sup', name: 'sup', accounts: [] }),
|
|
58
|
+
listModels: async (): Promise<ModelInfo[]> => [{ id: 'm1' }],
|
|
59
|
+
accounts: (): SupplierAccountNow[] => [{ uid: 'u1', credits: 0, state: 'ok' }],
|
|
60
|
+
getAlias: () => 'sup',
|
|
61
|
+
chatOnce: async (): Promise<ChatOnceResult> => ({ ok: true, stream }),
|
|
62
|
+
dispose: () => {},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 建 server + 发起「拿一块就断」的请求,返回服务端是否收尾。 */
|
|
67
|
+
async function abandonedRequest(
|
|
68
|
+
router: Router,
|
|
69
|
+
leak: { cancelled: () => boolean; enqueued: () => number },
|
|
70
|
+
): Promise<{ settled: boolean; closeSeen: boolean; enqueuedAfterSettle: number }> {
|
|
71
|
+
let settled = false
|
|
72
|
+
let closeSeen = false
|
|
73
|
+
const server = http.createServer((req, res) => {
|
|
74
|
+
let body = ''
|
|
75
|
+
req.on('data', (c) => { body += c })
|
|
76
|
+
req.on('end', () => {
|
|
77
|
+
res.on('close', () => { closeSeen = true })
|
|
78
|
+
void router.chatCompletions(
|
|
79
|
+
{ model: 'sup,m1', stream: true, rawBody: body } as never,
|
|
80
|
+
res as never,
|
|
81
|
+
).then(() => { settled = true }, () => { settled = true })
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
85
|
+
const port = (server.address() as { port: number }).port
|
|
86
|
+
|
|
87
|
+
await new Promise<void>((resolve) => {
|
|
88
|
+
const creq = http.request({
|
|
89
|
+
host: '127.0.0.1', port, path: '/x', method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
}, (cres) => {
|
|
92
|
+
cres.once('data', () => { creq.destroy(); resolve() })
|
|
93
|
+
})
|
|
94
|
+
creq.on('error', () => resolve())
|
|
95
|
+
creq.end(JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await new Promise((r) => setTimeout(r, 1200))
|
|
99
|
+
const enqueuedAfterSettle = leak.enqueued()
|
|
100
|
+
server.closeAllConnections?.()
|
|
101
|
+
server.close()
|
|
102
|
+
return { settled, closeSeen, enqueuedAfterSettle }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('客户端中途断开:服务端必须停手并 cancel 上游流(否则每放弃一次漏一条连接)', async () => {
|
|
106
|
+
const leak = infiniteStream()
|
|
107
|
+
const router = new Router('')
|
|
108
|
+
router.add(supplierWith(leak.stream) as never)
|
|
109
|
+
|
|
110
|
+
const { settled, closeSeen } = await abandonedRequest(router, leak)
|
|
111
|
+
|
|
112
|
+
assert.equal(closeSeen, true, 'server 应看到客户端 close')
|
|
113
|
+
assert.equal(settled, true, '客户端断开后请求处理必须收尾(不能永远挂着)')
|
|
114
|
+
assert.equal(leak.cancelled(), true, '上游流必须被 cancel(否则 fetch body 泄漏,累积到 connection error)')
|
|
115
|
+
|
|
116
|
+
// 断开后不能再继续消费上游
|
|
117
|
+
const a = leak.enqueued()
|
|
118
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
119
|
+
assert.equal(leak.enqueued(), a, '断开后不该继续从上游拉数据')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('客户端断开:上游流 cancel 链要穿过 tapStreamUsage / normalizeSSEStream 两层 wrapper', async () => {
|
|
123
|
+
// 带 probe(= 真实请求路径)时会多包一层 tapStreamUsage;两层 wrapper 都必须
|
|
124
|
+
// 实现 cancel→reader.cancel(),取消才到得了上游(缺一层就静默断链)。
|
|
125
|
+
const leak = infiniteStream()
|
|
126
|
+
const router = new Router('')
|
|
127
|
+
router.add(supplierWith(leak.stream) as never)
|
|
128
|
+
|
|
129
|
+
let settled = false
|
|
130
|
+
let closeSeen = false
|
|
131
|
+
const server = http.createServer((req, res) => {
|
|
132
|
+
let body = ''
|
|
133
|
+
req.on('data', (c) => { body += c })
|
|
134
|
+
req.on('end', () => {
|
|
135
|
+
res.on('close', () => { closeSeen = true })
|
|
136
|
+
void router.chatCompletions(
|
|
137
|
+
{ model: 'sup,m1', stream: true, rawBody: body } as never,
|
|
138
|
+
res as never,
|
|
139
|
+
).then(() => { settled = true }, () => { settled = true })
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
143
|
+
const port = (server.address() as { port: number }).port
|
|
144
|
+
await new Promise<void>((resolve) => {
|
|
145
|
+
const creq = http.request({
|
|
146
|
+
host: '127.0.0.1', port, path: '/x', method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
}, (cres) => { cres.once('data', () => { creq.destroy(); resolve() }) })
|
|
149
|
+
creq.on('error', () => resolve())
|
|
150
|
+
creq.end(JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }))
|
|
151
|
+
})
|
|
152
|
+
await new Promise((r) => setTimeout(r, 1200))
|
|
153
|
+
server.closeAllConnections?.()
|
|
154
|
+
server.close()
|
|
155
|
+
|
|
156
|
+
assert.equal(closeSeen, true)
|
|
157
|
+
assert.equal(settled, true, '带探针的路径也必须收尾')
|
|
158
|
+
assert.equal(leak.cancelled(), true, '取消必须穿过两层 wrapper 到达上游')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('聚合失败:reader 必须被解绑(否则连接还活着时上层无法 cancel)', async () => {
|
|
162
|
+
// 这一条测的**不是**「cancel 回调有没有触发」——那测不出东西:
|
|
163
|
+
// 实测结论(两个都验过):
|
|
164
|
+
// - 流 error 后调 cancel(),只会以同一错误 reject,**不触发** cancel 回调
|
|
165
|
+
// (`pull()` 抛错会把流置为 errored;上游拆连接则流直接 error)
|
|
166
|
+
// - 这两种情况下 undici **已经自己关了连接**,不需要我们 cancel
|
|
167
|
+
// 所以聚合失败路径真正要保证的是 **reader 被解绑**:流锁着的话,上层
|
|
168
|
+
// (writeChatResult 的失败路径 / 未来的取消逻辑)想 cancel 都会抛
|
|
169
|
+
// `Invalid state: ReadableStream is locked`,连接就永久漏了。
|
|
170
|
+
let releaseLocked = false
|
|
171
|
+
const enc = new TextEncoder()
|
|
172
|
+
const failing = new ReadableStream<Uint8Array>({
|
|
173
|
+
start(c) { c.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')) },
|
|
174
|
+
pull() { throw new Error('aggregate read failed') },
|
|
175
|
+
})
|
|
176
|
+
const origGetReader = failing.getReader.bind(failing)
|
|
177
|
+
;(failing as unknown as { getReader: () => unknown }).getReader = () => {
|
|
178
|
+
const r = origGetReader()
|
|
179
|
+
const origRelease = r.releaseLock.bind(r)
|
|
180
|
+
r.releaseLock = () => { releaseLocked = true; origRelease() }
|
|
181
|
+
return r
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const router = new Router('')
|
|
185
|
+
router.add(supplierWith(failing) as never)
|
|
186
|
+
const sink = {
|
|
187
|
+
writeHead: () => undefined, end: () => undefined, write: () => true,
|
|
188
|
+
once: () => undefined, removeListener: () => undefined, destroy: () => undefined,
|
|
189
|
+
writableEnded: false, destroyed: false,
|
|
190
|
+
} as never
|
|
191
|
+
|
|
192
|
+
await router.chatCompletions(
|
|
193
|
+
{ model: 'sup,m1', stream: false, rawBody: JSON.stringify({ model: 'sup,m1', messages: [] }) } as never,
|
|
194
|
+
sink,
|
|
195
|
+
)
|
|
196
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
197
|
+
|
|
198
|
+
assert.equal(releaseLocked, true, '聚合失败后必须 releaseLock,否则流锁死、上层无法回收')
|
|
199
|
+
assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
|
|
200
|
+
})
|
package/src/router/index.ts
CHANGED
|
@@ -140,6 +140,7 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
|
|
|
140
140
|
|
|
141
141
|
const reader = stream.getReader()
|
|
142
142
|
let buf = ''
|
|
143
|
+
try {
|
|
143
144
|
for (;;) {
|
|
144
145
|
const { done, value } = await reader.read()
|
|
145
146
|
if (done) break
|
|
@@ -189,6 +190,12 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
}
|
|
193
|
+
} finally {
|
|
194
|
+
// 无论正常读完还是中途抛错,都要**解绑** reader:调用方(writeChatResult)
|
|
195
|
+
// 在失败路径里要对同一流 cancel(),而流被锁住时 cancel 会抛
|
|
196
|
+
// `Invalid state: ReadableStream is locked` —— 那样这次的连接就漏了。
|
|
197
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
198
|
+
}
|
|
192
199
|
|
|
193
200
|
const toolCalls = [...calls.entries()]
|
|
194
201
|
.sort((a, b) => a[0] - b[0])
|
|
@@ -242,9 +249,11 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
|
|
|
242
249
|
const dec = new TextDecoder()
|
|
243
250
|
let buf = ''
|
|
244
251
|
let sawDone = false
|
|
252
|
+
/** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
253
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
245
254
|
const out = new ReadableStream<Uint8Array>({
|
|
246
255
|
start: async (ctrl) => {
|
|
247
|
-
|
|
256
|
+
reader = stream.getReader()
|
|
248
257
|
try {
|
|
249
258
|
for (;;) {
|
|
250
259
|
const { done, value } = await reader.read()
|
|
@@ -263,11 +272,21 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
|
|
|
263
272
|
if (isDoneFrame(buf)) sawDone = true
|
|
264
273
|
ctrl.enqueue(enc.encode(fixFrame(buf)))
|
|
265
274
|
}
|
|
275
|
+
} catch {
|
|
276
|
+
// 消费方取消(客户端断开)会让挂起的 read 抛错/返回 done —— 静默收尾
|
|
266
277
|
} finally {
|
|
267
|
-
reader.releaseLock()
|
|
278
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
268
279
|
}
|
|
269
|
-
ctrl.close()
|
|
280
|
+
try { ctrl.close() } catch { /* 已取消 */ }
|
|
270
281
|
},
|
|
282
|
+
/**
|
|
283
|
+
* **取消必须往下传**:ReadableStream 的取消不会自动穿过 wrapper。
|
|
284
|
+
* 不实现这个分支,客户端断开的取消就停在这里,上游 fetch body 永不 cancel
|
|
285
|
+
* ——那条 TCP 连接和 in-flight 请求原样挂着(长跑一天就会累积到耗尽)。
|
|
286
|
+
* 用 reader.cancel() 而不是 stream.cancel():流已被 getReader() 锁住,
|
|
287
|
+
* 直接 cancel 会抛 `Invalid state: ReadableStream is locked`。
|
|
288
|
+
*/
|
|
289
|
+
cancel: (reason) => reader?.cancel(reason),
|
|
271
290
|
})
|
|
272
291
|
return { stream: out, sawDone: () => sawDone }
|
|
273
292
|
}
|
|
@@ -338,7 +357,12 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
338
357
|
try {
|
|
339
358
|
body = await aggregateSSE(r.stream)
|
|
340
359
|
} catch {
|
|
341
|
-
|
|
360
|
+
// 聚合失败 = 一个字节都没写,调用方可以换号重试。
|
|
361
|
+
// **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
|
|
362
|
+
// reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
|
|
363
|
+
// 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
|
|
364
|
+
await r.stream.cancel().catch(() => {})
|
|
365
|
+
return false
|
|
342
366
|
}
|
|
343
367
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
|
|
344
368
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
@@ -351,6 +375,18 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
351
375
|
let wroteAny = false
|
|
352
376
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
353
377
|
let dead = false
|
|
378
|
+
/**
|
|
379
|
+
* 唤醒「等 drain 的挂起 write」。客户端断开时必须调它:那时 'drain' 永不到来,
|
|
380
|
+
* 不唤醒就永远卡在 write 里,pipeTo 不 settle,finally 不执行,
|
|
381
|
+
* **上游流也就永不 cancel**(= 每次客户端放弃漏一条连接)。
|
|
382
|
+
*/
|
|
383
|
+
let releaseWait: (() => void) | undefined
|
|
384
|
+
/**
|
|
385
|
+
* 中止 pipeTo 的信号源(客户端断开时 abort)。
|
|
386
|
+
* 单靠 `dead` 标志只能让**下一次** write 立刻返回,唤醒不了**已经挂起**的那次;
|
|
387
|
+
* abort 才能让 pipeTo 立刻收尾并走它的取消路径(进而 cancel 上游)。
|
|
388
|
+
*/
|
|
389
|
+
const abort = new AbortController()
|
|
354
390
|
const writeChunk = (chunk: Uint8Array): Promise<void> =>
|
|
355
391
|
new Promise<void>((resolve) => {
|
|
356
392
|
if (dead) { resolve(); return }
|
|
@@ -370,25 +406,44 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
370
406
|
if (res.write(chunk)) resolve()
|
|
371
407
|
else {
|
|
372
408
|
// 背压:等 drain。但连接可能在这期间断掉 —— 那时 'drain' 永不到来,
|
|
373
|
-
// 得靠
|
|
409
|
+
// 得靠 release() 兜住(客户端断开时唤醒),否则这次请求会永远挂着。
|
|
374
410
|
const onDrain = (): void => { res.removeListener('error', onDrain); resolve() }
|
|
375
411
|
res.once('drain', onDrain)
|
|
412
|
+
/** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
|
|
413
|
+
releaseWait = () => { res.removeListener('drain', onDrain); resolve() }
|
|
376
414
|
}
|
|
377
415
|
})
|
|
378
416
|
|
|
379
|
-
//
|
|
417
|
+
// 客户端断开 / 流中途出错都要停手,否则连接悬挂
|
|
380
418
|
const onError = (): void => {
|
|
381
419
|
dead = true
|
|
382
420
|
res.destroy()
|
|
383
421
|
}
|
|
384
422
|
res.once('error', onError)
|
|
423
|
+
/**
|
|
424
|
+
* **客户端断开必须被当成取消信号**:只监听 'error' 是不够的 —— 对端关闭
|
|
425
|
+
* (pi-ai 的流空闲超时、用户取消、消费方提前退出)触发的是 'close',
|
|
426
|
+
* 不是 'error'。此时若不收手,pipeTo 会一直等着 drain(永不到来),
|
|
427
|
+
* finally 永不执行,上游 fetch body **永不 cancel** → 那条 TCP 连接和
|
|
428
|
+
* in-flight 请求原样挂着,长跑累积到耗尽("用久了就报 connection error")。
|
|
429
|
+
*/
|
|
430
|
+
const onClose = (): void => {
|
|
431
|
+
if (res.writableEnded) return // 正常写完的 close,不是取消
|
|
432
|
+
dead = true
|
|
433
|
+
releaseWait?.()
|
|
434
|
+
abort.abort()
|
|
435
|
+
}
|
|
436
|
+
res.once('close', onClose)
|
|
385
437
|
const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
|
|
386
438
|
try {
|
|
387
|
-
|
|
439
|
+
// signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
|
|
440
|
+
// cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
|
|
441
|
+
await norm.stream.pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }), { signal: abort.signal })
|
|
388
442
|
} catch {
|
|
389
|
-
//
|
|
443
|
+
// 断流/取消:一个字节都没写的话,调用方还能换号重试——交给上层决定
|
|
390
444
|
} finally {
|
|
391
445
|
res.removeListener('error', onError)
|
|
446
|
+
res.removeListener('close', onClose)
|
|
392
447
|
// 断流也要给客户端一个终止帧。客户端(dsh-llm adapter)严格等
|
|
393
448
|
// `[DONE]`,缺了就整轮判失败(`SSE payload stream ended without
|
|
394
449
|
// [DONE]`),已经流式吐出去的内容全部作废。上游自己发了就不重复发
|
|
@@ -396,10 +451,11 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
396
451
|
//
|
|
397
452
|
// 只在**已提交响应**时补:一个字节都没写 = 还能换号重试,补了就等于
|
|
398
453
|
// 把这次失败坐实成一个空响应,组合回退就没了。
|
|
454
|
+
// dead(客户端已走)时也不补:对端没了,写了也没人收。
|
|
399
455
|
if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) {
|
|
400
456
|
await writeChunk(DONE_FRAME)
|
|
401
457
|
}
|
|
402
|
-
if (wroteAny) res.end()
|
|
458
|
+
if (wroteAny && !dead) res.end()
|
|
403
459
|
// 流走完才出得了 usage(上游多在最后一帧才发 usage)
|
|
404
460
|
if (probe !== undefined && tapped !== undefined) {
|
|
405
461
|
const got = await tapped.done()
|
|
@@ -61,7 +61,14 @@ function num(v: unknown): number {
|
|
|
61
61
|
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
62
62
|
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
63
63
|
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
64
|
-
*
|
|
64
|
+
*
|
|
65
|
+
* 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
|
|
66
|
+
* Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
|
|
67
|
+
* 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
|
|
68
|
+
* 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
|
|
69
|
+
* 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
|
|
70
|
+
* 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
|
|
71
|
+
* 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
|
|
65
72
|
*
|
|
66
73
|
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
67
74
|
*/
|
|
@@ -77,7 +84,11 @@ export function normalizeUsage(raw: unknown): UsageTokens | null {
|
|
|
77
84
|
|
|
78
85
|
// promptTokenCount 是 Gemini 形态;input_tokens 是 Claude 形态
|
|
79
86
|
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount)
|
|
80
|
-
|
|
87
|
+
// 有 prompt_tokens = OpenAI 总语义(含缓存);没有 = Claude/Gemini 不含缓存,
|
|
88
|
+
// 这时才该把 cache_read 折进来,否则像 TRAE 会加两遍
|
|
89
|
+
if (u.cache_read_input_tokens !== undefined && u.prompt_tokens === undefined) {
|
|
90
|
+
prompt += num(u.cache_read_input_tokens)
|
|
91
|
+
}
|
|
81
92
|
|
|
82
93
|
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount)
|
|
83
94
|
|
|
@@ -269,9 +280,11 @@ export function tapStreamUsage(
|
|
|
269
280
|
}
|
|
270
281
|
}
|
|
271
282
|
|
|
283
|
+
/** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
284
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
272
285
|
const stream = new ReadableStream<Uint8Array>({
|
|
273
286
|
start: async (ctrl) => {
|
|
274
|
-
|
|
287
|
+
reader = source.getReader()
|
|
275
288
|
try {
|
|
276
289
|
for (;;) {
|
|
277
290
|
const { done, value } = await reader.read()
|
|
@@ -289,11 +302,19 @@ export function tapStreamUsage(
|
|
|
289
302
|
ctrl.enqueue(value as Uint8Array)
|
|
290
303
|
}
|
|
291
304
|
if (buf !== '') parseFrame(buf)
|
|
305
|
+
} catch {
|
|
306
|
+
// 消费方取消会让挂起的 read 收尾 —— 静默退出
|
|
292
307
|
} finally {
|
|
293
|
-
reader.releaseLock()
|
|
308
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
294
309
|
}
|
|
295
|
-
ctrl.close()
|
|
310
|
+
try { ctrl.close() } catch { /* 已取消 */ }
|
|
296
311
|
},
|
|
312
|
+
/**
|
|
313
|
+
* 取消要往下传(同 normalizeSSEStream 的理由):不传 → 客户端断开时
|
|
314
|
+
* 上游 fetch body 永不 cancel,连接与 in-flight 请求一直挂着。
|
|
315
|
+
* 必须用 reader.cancel():流已被 getReader() 锁住,stream.cancel() 会抛。
|
|
316
|
+
*/
|
|
317
|
+
cancel: (reason) => reader?.cancel(reason),
|
|
297
318
|
})
|
|
298
319
|
|
|
299
320
|
return {
|
package/src/router/usage.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
tapStreamUsage,
|
|
13
13
|
withEstimates,
|
|
14
14
|
estimateTokens,
|
|
15
|
+
toTokenUsage,
|
|
15
16
|
} from './usage-tokens.ts'
|
|
16
17
|
import { UsageStore, localDateKey } from './usage-store.ts'
|
|
17
18
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
@@ -35,6 +36,22 @@ test('归一化:Claude 形态的 prompt 不含缓存,cache_read 要折进输
|
|
|
35
36
|
assert.deepEqual(u, { promptTokens: 130, completionTokens: 10, cachedTokens: 80 })
|
|
36
37
|
})
|
|
37
38
|
|
|
39
|
+
test('归一化:TRAE 借 Claude 字段名、语义却是 OpenAI —— 不得重复加 cache(命中率腰斩 bug)', () => {
|
|
40
|
+
// TRAE(SOLO) 同时报 prompt_tokens(OpenAI 总语义,**已含**缓存)和
|
|
41
|
+
// cache_read_input_tokens。语义与字段名分属两条车道,判别不能只看后者。
|
|
42
|
+
// 2026-09-16 实测同一次请求:160019 + 159744 被加两遍 → 319763,命中率腰斩
|
|
43
|
+
// 成 ~50%;真实命中率 159744/160019 = 99.8%。
|
|
44
|
+
const u = normalizeUsage({
|
|
45
|
+
prompt_tokens: 160019,
|
|
46
|
+
completion_tokens: 307,
|
|
47
|
+
cache_read_input_tokens: 159744,
|
|
48
|
+
})
|
|
49
|
+
assert.deepEqual(u, { promptTokens: 160019, completionTokens: 307, cachedTokens: 159744 }, 'prompt_tokens 已含缓存,不得再加 cache_read')
|
|
50
|
+
// DISJOINT 折算后必须还原上游真实值:160019 - 159744 = 275 未缓存输入
|
|
51
|
+
const t = toTokenUsage(u)
|
|
52
|
+
assert.deepEqual(t, { inputTokens: 275, outputTokens: 307, cacheReadTokens: 159744 })
|
|
53
|
+
})
|
|
54
|
+
|
|
38
55
|
test('归一化:Gemini 形态(promptTokenCount / usageMetadata)', () => {
|
|
39
56
|
const u = normalizeUsage({ promptTokenCount: 7, candidatesTokenCount: 3, cachedContentTokenCount: 2 })
|
|
40
57
|
assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, cachedTokens: 2 })
|