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/src/client/ExtTab.tsx
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useRef, useState } from 'react'
|
|
12
12
|
import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
|
|
13
|
+
import { ExtDetail } from './ExtDetail.tsx'
|
|
13
14
|
|
|
14
15
|
function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
15
16
|
return (
|
|
@@ -28,6 +29,7 @@ export function ExtTab(): JSX.Element {
|
|
|
28
29
|
const [items, setItems] = useState<RouterExtItem[]>([])
|
|
29
30
|
const [loaded, setLoaded] = useState(false)
|
|
30
31
|
const [error, setError] = useState('')
|
|
32
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
31
33
|
const mounted = useRef(true)
|
|
32
34
|
|
|
33
35
|
useEffect(() => {
|
|
@@ -79,6 +81,12 @@ export function ExtTab(): JSX.Element {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// 打开详情视图:用 live item(否则详情里的开关状态不会随列表更新)。
|
|
85
|
+
const selected = selectedId !== null ? items.find((i) => i.id === selectedId) ?? null : null
|
|
86
|
+
if (selected !== null) {
|
|
87
|
+
return <ExtDetail item={selected} onBack={() => setSelectedId(null)} onToggle={toggle} />
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
return (
|
|
83
91
|
<div className="dshr-tabBody">
|
|
84
92
|
{error !== '' && (
|
|
@@ -92,51 +100,43 @@ export function ExtTab(): JSX.Element {
|
|
|
92
100
|
<span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
|
|
93
101
|
<p className="dshr-keyEmptyTitle">暂无扩展</p>
|
|
94
102
|
<p className="dshr-keyEmptyDesc">
|
|
95
|
-
安装扩展插件(如 <code>dsh-router-ext-
|
|
103
|
+
安装扩展插件(如 <code>dsh-router-ext-watch</code>)后,这里会出现开关
|
|
96
104
|
</p>
|
|
97
105
|
</div>
|
|
98
106
|
) : (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? '当前不可用,无法开启'
|
|
118
|
-
: item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
|
|
119
|
-
>
|
|
120
|
-
<span className="dshr-toggleKnob" />
|
|
121
|
-
</button>
|
|
122
|
-
</div>
|
|
107
|
+
<div className="dshr-supplierGrid">
|
|
108
|
+
{items.map(item => {
|
|
109
|
+
const notReady = item.ready === false
|
|
110
|
+
return (
|
|
111
|
+
<section key={item.id} className="dshr-supplierCard" onClick={() => setSelectedId(item.id)}>
|
|
112
|
+
<div className="dshr-supplierRow">
|
|
113
|
+
{item.icon !== undefined ? (
|
|
114
|
+
<img
|
|
115
|
+
className="dshr-supplierIcon dshr-extLogo"
|
|
116
|
+
src={item.icon}
|
|
117
|
+
alt=""
|
|
118
|
+
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<span className="dshr-supplierIcon"><Icon d={I.bolt} size={24} /></span>
|
|
122
|
+
)}
|
|
123
|
+
<div className="dshr-supplierName">{item.name}</div>
|
|
124
|
+
</div>
|
|
123
125
|
|
|
124
|
-
{/* 内容区:说明 + 未就绪红字(左对齐、与卡片图标对齐) */}
|
|
125
|
-
<div className="dshr-extBody">
|
|
126
|
-
{item.description !== undefined && item.description !== '' && (
|
|
127
|
-
<div className="dshr-muted">{item.description}</div>
|
|
128
|
-
)}
|
|
129
126
|
{notReady && (
|
|
130
|
-
<div className="dshr-
|
|
131
|
-
{item.detail !== undefined && item.detail !== ''
|
|
132
|
-
? item.detail
|
|
133
|
-
: '未就绪,无法开启'}
|
|
127
|
+
<div className="dshr-extCardError">
|
|
128
|
+
{item.detail !== undefined && item.detail !== '' ? item.detail : '未就绪'}
|
|
134
129
|
</div>
|
|
135
130
|
)}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
|
|
132
|
+
<div className="dshr-supplierMeta">
|
|
133
|
+
<span className="dshr-muted">{item.enabled ? '已启用' : '未启用'}</span>
|
|
134
|
+
<span className="dshr-chevron">›</span>
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
)
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
140
|
)}
|
|
141
141
|
</div>
|
|
142
142
|
)
|
package/src/client/router.css
CHANGED
|
@@ -923,6 +923,34 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
|
|
|
923
923
|
margin-top: 4px;
|
|
924
924
|
}
|
|
925
925
|
|
|
926
|
+
/* watch 进程行动作区(复制 PID / kill 命令,只读) */
|
|
927
|
+
.dshr-procOps {
|
|
928
|
+
display: inline-flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
justify-content: flex-end;
|
|
931
|
+
gap: 4px;
|
|
932
|
+
white-space: nowrap;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* 扩展供应商式小卡片:未就绪红字(描述只在详情页,卡片不放) */
|
|
936
|
+
.dshr-extCardError {
|
|
937
|
+
color: var(--rs-danger);
|
|
938
|
+
font-size: 11px;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* 扩展卡 logo(如 rtk 白底 logo):黑底 + 内边距,白色 logo 在黑底上显形 */
|
|
942
|
+
.dshr-extLogo {
|
|
943
|
+
background: #000;
|
|
944
|
+
padding: 3px;
|
|
945
|
+
box-sizing: border-box;
|
|
946
|
+
object-fit: contain;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* 扩展详情头部右侧的开关(顶到标题行右边,同供应商详情的编辑按钮位) */
|
|
950
|
+
.dshr-extDetailToggle {
|
|
951
|
+
margin-left: auto;
|
|
952
|
+
}
|
|
953
|
+
|
|
926
954
|
/* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
|
|
927
955
|
.dshr-extBody {
|
|
928
956
|
padding: 10px 14px 12px 48px;
|
package/src/ext/contract.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
26
|
*/
|
|
27
27
|
import type { Context } from '@deepseek-ai/cordis'
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
29
|
|
|
29
30
|
/** 扩展器唯一 id(注册键)。 */
|
|
30
31
|
export type ExtId = string
|
|
@@ -53,8 +54,16 @@ export interface RouterExt {
|
|
|
53
54
|
readonly name: string
|
|
54
55
|
/** 面板副标题说明。 */
|
|
55
56
|
readonly description?: string
|
|
57
|
+
/** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
|
|
58
|
+
readonly icon?: string
|
|
56
59
|
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
57
60
|
getState(): ExtState
|
|
61
|
+
/**
|
|
62
|
+
* 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
|
|
63
|
+
* 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
|
|
64
|
+
* false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
|
|
65
|
+
*/
|
|
66
|
+
api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>
|
|
58
67
|
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
59
68
|
dispose?(): void
|
|
60
69
|
}
|
|
@@ -64,6 +73,8 @@ export interface ExtInfo {
|
|
|
64
73
|
id: string
|
|
65
74
|
name: string
|
|
66
75
|
description?: string
|
|
76
|
+
/** 卡片/详情图标 logo URL(可选)。 */
|
|
77
|
+
icon?: string
|
|
67
78
|
/** 核心持久化的开关(默认关)。 */
|
|
68
79
|
enabled: boolean
|
|
69
80
|
/** 插件报的运行时就绪状态。 */
|
package/src/index.ts
CHANGED
|
@@ -525,6 +525,7 @@ export function apply(rawContext: unknown): void {
|
|
|
525
525
|
id: e.id,
|
|
526
526
|
name: e.name ?? e.id,
|
|
527
527
|
...(e.description !== undefined ? { description: e.description } : {}),
|
|
528
|
+
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
528
529
|
enabled: extStore.isEnabled(e.id),
|
|
529
530
|
ready: st?.ready === true,
|
|
530
531
|
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
@@ -534,6 +535,34 @@ export function apply(rawContext: unknown): void {
|
|
|
534
535
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
535
536
|
})
|
|
536
537
|
|
|
538
|
+
// ---- 扩展通用 API 转发:/router/api/ext/{id}/{subpath} → 该扩展器的 api() ----
|
|
539
|
+
// watch 这类自定义扩展用它暴露自己的面板接口(如 processes)。分开的 exact 路由
|
|
540
|
+
// `/ext`(上方)管开关列表;这里必须是 **prefix** 路由兜子路径(`route()` 是 exact,
|
|
541
|
+
// 匹配不了 `/ext/watch/processes`——用 exact 会造成 404 空 body → 前端 res.json() 崩)。
|
|
542
|
+
// 核心不知道子路径长什么样——是否处理、怎么处理全由扩展器自己裁决(执行面),核心只转发。
|
|
543
|
+
const extDispatch = ctx.webServer.register({
|
|
544
|
+
kind: 'prefix',
|
|
545
|
+
path: `${ROUTER_API_BASE}/ext/`,
|
|
546
|
+
handler: async (req, res) => {
|
|
547
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
|
|
548
|
+
const mount = `${ROUTER_API_BASE}/ext/`
|
|
549
|
+
const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : ''
|
|
550
|
+
const slash = rest.indexOf('/')
|
|
551
|
+
const extId = slash < 0 ? rest : rest.slice(0, slash)
|
|
552
|
+
const subpath = slash < 0 ? '' : rest.slice(slash + 1)
|
|
553
|
+
if (extId === '') {
|
|
554
|
+
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const ext = (exts[extId] as RouterExt | undefined)
|
|
558
|
+
if (ext !== undefined && typeof ext.api === 'function' && (await ext.api(subpath, req, res))) {
|
|
559
|
+
return // 扩展器已处理并写出响应
|
|
560
|
+
}
|
|
561
|
+
writeJson(res, 404, { ok: false, error: 'extension api not found' })
|
|
562
|
+
},
|
|
563
|
+
})
|
|
564
|
+
disposers.push(extDispatch)
|
|
565
|
+
|
|
537
566
|
// ---- 概览看板:用量统计 ----
|
|
538
567
|
|
|
539
568
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
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
|
+
})
|