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/README.md +1 -1
- package/lib/client-registry.js +449 -125
- package/lib/client.js +449 -125
- package/lib/index.js +243 -102
- package/lib/suppliers/opencode.js +103 -19
- package/lib/types/ext/contract.d.ts +11 -0
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/shared.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/ExtDetail.tsx +257 -0
- package/src/client/ExtTab.tsx +38 -38
- package/src/client/router.css +28 -0
- package/src/ext/contract.ts +11 -0
- package/src/index.ts +29 -0
- 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 +13 -3
- package/src/shared.ts +21 -0
- package/src/suppliers/opencode/plugin.test.ts +176 -0
- package/src/suppliers/opencode/plugin.ts +103 -21
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({
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1258
|
+
if (buf !== "") parseFrame(buf);
|
|
1259
|
+
} catch {} finally {
|
|
1260
|
+
try {
|
|
1261
|
+
reader.releaseLock();
|
|
1262
|
+
} catch {}
|
|
1254
1263
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
}
|
|
1259
|
-
|
|
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
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
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({
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
|
|
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
|
|
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";
|