dsh-router-core 0.3.18 → 0.3.20

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/index.js CHANGED
@@ -1235,29 +1235,43 @@ function tapStreamUsage(source, startedAt) {
1235
1235
  outputChars += frameText(obj).length;
1236
1236
  }
1237
1237
  };
1238
+ /** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
1239
+ let reader;
1238
1240
  return {
1239
- stream: new ReadableStream({ start: async (ctrl) => {
1240
- const reader = source.getReader();
1241
- try {
1242
- for (;;) {
1243
- const { done, value } = await reader.read();
1244
- if (done) break;
1245
- if (firstAt === 0) firstAt = Date.now();
1246
- buf += dec.decode(value, { stream: true });
1247
- let sep;
1248
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1249
- const frame = buf.slice(0, sep + 2);
1250
- buf = buf.slice(sep + 2);
1251
- parseFrame(frame);
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);
1252
1257
  }
1253
- ctrl.enqueue(value);
1258
+ if (buf !== "") parseFrame(buf);
1259
+ } catch {} finally {
1260
+ try {
1261
+ reader.releaseLock();
1262
+ } catch {}
1254
1263
  }
1255
- if (buf !== "") parseFrame(buf);
1256
- } finally {
1257
- reader.releaseLock();
1258
- }
1259
- ctrl.close();
1260
- } }),
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
+ }),
1261
1275
  done: async () => ({
1262
1276
  usage: merged,
1263
1277
  outputChars,
@@ -1399,59 +1413,65 @@ async function aggregateSSE(stream) {
1399
1413
  const calls = /* @__PURE__ */ new Map();
1400
1414
  const reader = stream.getReader();
1401
1415
  let buf = "";
1402
- for (;;) {
1403
- const { done, value } = await reader.read();
1404
- if (done) break;
1405
- buf += dec.decode(value, { stream: true });
1406
- let sep;
1407
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1408
- const frame = buf.slice(0, sep);
1409
- buf = buf.slice(sep + 2);
1410
- for (const line of frame.split("\n")) {
1411
- if (!line.startsWith("data:")) continue;
1412
- const payload = line.slice(5).trim();
1413
- if (payload === "" || payload === "[DONE]") continue;
1414
- let obj;
1415
- try {
1416
- obj = JSON.parse(payload);
1417
- } catch {
1418
- continue;
1419
- }
1420
- if (typeof obj.id === "string" && obj.id !== "") id = obj.id;
1421
- if (typeof obj.model === "string" && obj.model !== "") model = obj.model;
1422
- if (typeof obj.created === "number") created = obj.created;
1423
- if (obj.usage !== void 0) usage = obj.usage;
1424
- const ch = obj.choices?.[0];
1425
- if (ch === void 0) continue;
1426
- if (typeof ch.finish_reason === "string") finish = ch.finish_reason;
1427
- const delta = ch.delta;
1428
- if (delta === void 0) {
1429
- const msg = ch.message;
1430
- if (msg !== void 0) {
1431
- if (typeof msg.content === "string") content += msg.content;
1432
- if (typeof msg.reasoning_content === "string") reasoning += msg.reasoning_content;
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);
1433
1467
  }
1434
- continue;
1435
- }
1436
- if (typeof delta.content === "string") content += delta.content;
1437
- if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
1438
- const tcs = delta.tool_calls;
1439
- if (!Array.isArray(tcs)) continue;
1440
- for (const tc of tcs) {
1441
- const idx = typeof tc.index === "number" ? tc.index : 0;
1442
- const fn = tc.function;
1443
- const prev = calls.get(idx) ?? {
1444
- name: "",
1445
- args: ""
1446
- };
1447
- if (typeof tc.id === "string" && tc.id !== "") prev.id = tc.id;
1448
- if (typeof tc.type === "string") prev.type = tc.type;
1449
- if (typeof fn?.name === "string" && fn.name !== "") prev.name = fn.name;
1450
- if (typeof fn?.arguments === "string") prev.args += fn.arguments;
1451
- calls.set(idx, prev);
1452
1468
  }
1453
1469
  }
1454
1470
  }
1471
+ } finally {
1472
+ try {
1473
+ reader.releaseLock();
1474
+ } catch {}
1455
1475
  }
1456
1476
  const toolCalls = [...calls.entries()].sort((a, b) => a[0] - b[0]).filter(([, c]) => c.name !== "").map(([, c]) => ({
1457
1477
  id: c.id ?? `call_${c.name}`,
@@ -1505,31 +1525,47 @@ function normalizeSSEStream(stream) {
1505
1525
  const dec = new TextDecoder();
1506
1526
  let buf = "";
1507
1527
  let sawDone = false;
1528
+ /** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
1529
+ let reader;
1508
1530
  return {
1509
- stream: new ReadableStream({ start: async (ctrl) => {
1510
- const reader = stream.getReader();
1511
- try {
1512
- for (;;) {
1513
- const { done, value } = await reader.read();
1514
- if (done) break;
1515
- buf += dec.decode(value, { stream: true });
1516
- let sep;
1517
- while ((sep = buf.indexOf("\n\n")) >= 0) {
1518
- const frame = buf.slice(0, sep + 2);
1519
- buf = buf.slice(sep + 2);
1520
- if (isDoneFrame(frame)) sawDone = true;
1521
- ctrl.enqueue(enc.encode(fixFrame(frame)));
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
+ }
1522
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 {}
1523
1555
  }
1524
- if (buf !== "") {
1525
- if (isDoneFrame(buf)) sawDone = true;
1526
- ctrl.enqueue(enc.encode(fixFrame(buf)));
1527
- }
1528
- } finally {
1529
- reader.releaseLock();
1530
- }
1531
- ctrl.close();
1532
- } }),
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
+ }),
1533
1569
  sawDone: () => sawDone
1534
1570
  };
1535
1571
  }
@@ -1599,6 +1635,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1599
1635
  try {
1600
1636
  body = await aggregateSSE(r.stream);
1601
1637
  } catch {
1638
+ await r.stream.cancel().catch(() => {});
1602
1639
  return false;
1603
1640
  }
1604
1641
  if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
@@ -1613,6 +1650,18 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1613
1650
  let wroteAny = false;
1614
1651
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
1615
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();
1616
1665
  const writeChunk = (chunk) => new Promise((resolve) => {
1617
1666
  if (dead) {
1618
1667
  resolve();
@@ -1635,6 +1684,11 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1635
1684
  resolve();
1636
1685
  };
1637
1686
  res.once("drain", onDrain);
1687
+ /** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
1688
+ releaseWait = () => {
1689
+ res.removeListener("drain", onDrain);
1690
+ resolve();
1691
+ };
1638
1692
  }
1639
1693
  });
1640
1694
  const onError = () => {
@@ -1642,13 +1696,28 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1642
1696
  res.destroy();
1643
1697
  };
1644
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);
1645
1713
  const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
1646
1714
  try {
1647
- await norm.stream.pipeTo(new WritableStream({ write: writeChunk }));
1715
+ await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
1648
1716
  } catch {} finally {
1649
1717
  res.removeListener("error", onError);
1718
+ res.removeListener("close", onClose);
1650
1719
  if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) await writeChunk(DONE_FRAME);
1651
- if (wroteAny) res.end();
1720
+ if (wroteAny && !dead) res.end();
1652
1721
  if (probe !== void 0 && tapped !== void 0) {
1653
1722
  const got = await tapped.done();
1654
1723
  probe.tokens = got.usage;
@@ -2315,6 +2384,16 @@ var Router = class {
2315
2384
  */
2316
2385
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2317
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;
2318
2397
  async function wireMessages(options, system, attachments) {
2319
2398
  const out = [];
2320
2399
  /**
@@ -2756,11 +2835,18 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2756
2835
  baseURL;
2757
2836
  source;
2758
2837
  resolveAttachments;
2759
- constructor(baseURL, source, resolveAttachments) {
2838
+ /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
2839
+ idleTimeoutMs;
2840
+ /**
2841
+ * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
2842
+ * 可注入只为测试能压到几百毫秒,生产不传。
2843
+ */
2844
+ constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
2760
2845
  super();
2761
2846
  this.baseURL = baseURL;
2762
2847
  this.source = source;
2763
2848
  this.resolveAttachments = resolveAttachments ?? (() => void 0);
2849
+ this.idleTimeoutMs = idleTimeoutMs;
2764
2850
  }
2765
2851
  providerInfo(provider) {
2766
2852
  return {
@@ -2824,16 +2910,19 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2824
2910
  signal: controller.signal
2825
2911
  });
2826
2912
  } catch (error) {
2913
+ options.signal?.removeEventListener("abort", onAbort);
2827
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));
2828
2923
  } finally {
2829
2924
  options.signal?.removeEventListener("abort", onAbort);
2830
2925
  }
2831
- if (!resp.ok) {
2832
- const detail = await resp.text().catch(() => "");
2833
- throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, "TRANSPORT");
2834
- }
2835
- if (resp.body === null) return;
2836
- yield* translateSse(ssePayloads(resp.body));
2837
2926
  }
2838
2927
  };
2839
2928
  /**
@@ -2861,16 +2950,41 @@ function looksComplete(payload) {
2861
2950
  * 跨块边界:一个 `data:` 行可能横跨两次 read(),所以留 `buffer` 接住
2862
2951
  * 最后一段未完整的行(9router 的 pipeTransformedSSE 同样处理)。
2863
2952
  */
2864
- async function* ssePayloads(body) {
2953
+ async function* ssePayloads(body, idleTimeoutMs) {
2865
2954
  const reader = body.getReader();
2866
2955
  const decoder = new TextDecoder("utf-8", { fatal: false });
2867
2956
  let buffer = "";
2868
2957
  /** 攒一帧内的多行 data(SSE 规范:多行用 \n 连起来)。**必须**是局部
2869
2958
  * 状态 —— 放模块级会让并发请求互相串数据。 */
2870
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
+ });
2871
2985
  try {
2872
2986
  for (;;) {
2873
- const { done, value } = await reader.read();
2987
+ const { done, value } = await readWithIdleTimeout();
2874
2988
  if (done) break;
2875
2989
  buffer += decoder.decode(value, { stream: true });
2876
2990
  const lines = buffer.split("\n");
@@ -4210,6 +4324,7 @@ function apply(rawContext) {
4210
4324
  id: e.id,
4211
4325
  name: e.name ?? e.id,
4212
4326
  ...e.description !== void 0 ? { description: e.description } : {},
4327
+ ...e.icon !== void 0 ? { icon: e.icon } : {},
4213
4328
  enabled: extStore.isEnabled(e.id),
4214
4329
  ready: st?.ready === true,
4215
4330
  ...st?.detail !== void 0 ? { detail: st.detail } : {}
@@ -4217,6 +4332,32 @@ function apply(rawContext) {
4217
4332
  }).filter((e) => e !== void 0)
4218
4333
  });
4219
4334
  });
4335
+ const extDispatch = ctx.webServer.register({
4336
+ kind: "prefix",
4337
+ path: `${ROUTER_API_BASE}/ext/`,
4338
+ handler: async (req, res) => {
4339
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4340
+ const mount = `${ROUTER_API_BASE}/ext/`;
4341
+ const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
4342
+ const slash = rest.indexOf("/");
4343
+ const extId = slash < 0 ? rest : rest.slice(0, slash);
4344
+ const subpath = slash < 0 ? "" : rest.slice(slash + 1);
4345
+ if (extId === "") {
4346
+ writeJson(res, 404, {
4347
+ ok: false,
4348
+ error: "extension not found"
4349
+ });
4350
+ return;
4351
+ }
4352
+ const ext = exts[extId];
4353
+ if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
4354
+ writeJson(res, 404, {
4355
+ ok: false,
4356
+ error: "extension api not found"
4357
+ });
4358
+ }
4359
+ });
4360
+ disposers.push(extDispatch);
4220
4361
  /** 解析并校验周期参数(不接受任意字符串)。 */
4221
4362
  const periodOf = (raw) => {
4222
4363
  return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";