dsh-router-core 0.4.0 → 0.4.2
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 +174 -38
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +29 -6
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +939 -132
- package/lib/client.js +939 -132
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +838 -89
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -0
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/llm/adapter.d.ts +40 -4
- package/lib/types/router/index.d.ts +61 -8
- package/lib/types/shared.d.ts +12 -0
- package/lib/types/supplier-config.d.ts +15 -0
- package/lib/types/suppliers/contract.d.ts +35 -2
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -1
- package/package.json +39 -4
- package/src/client/ExtDetail.tsx +21 -22
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +41 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +490 -9
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -0
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/index.ts +46 -11
- package/src/llm/adapter.test.ts +101 -4
- package/src/llm/adapter.ts +89 -20
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +255 -35
- package/src/router/models-cache.test.ts +45 -0
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/shared.ts +12 -0
- package/src/supplier-config.ts +32 -1
- package/src/suppliers/contract.ts +54 -2
- package/src/suppliers/loader.test.ts +36 -2
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +102 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自检扩展(`router.ext` 侧)测试 —— 锁住三件事:
|
|
3
|
+
* 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
|
|
4
|
+
* 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
|
|
5
|
+
* 3. 恒为就绪 —— 自检能力不依赖外部二进制,不该提前否决(真跑一次才知道)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么测这个:扩展的「卡片出现与否」完全取决于它在不在这张表里,而表是核心
|
|
8
|
+
* provide 的**同一个 live 对象**。登记没发生的话,面板与插件页都看不到它,且不报错。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { createTestExt, EXT_TEST_ID } from './plugin.ts'
|
|
13
|
+
import { apply, name as CORDIS_NAME } from './index.ts'
|
|
14
|
+
import type { RouterExtService } from '../ext/contract.ts'
|
|
15
|
+
|
|
16
|
+
test('扩展器带上面板要用的身份,且恒为就绪', () => {
|
|
17
|
+
const ext = createTestExt()
|
|
18
|
+
assert.equal(ext.id, EXT_TEST_ID)
|
|
19
|
+
assert.equal(ext.name, '插件自检')
|
|
20
|
+
assert.ok((ext.description ?? '').length > 0, '没有说明,插件页那一行就没有副标题')
|
|
21
|
+
assert.equal(ext.getState().ready, true, '自检不依赖本机二进制,不该报未就绪')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('cordis 身份是行 id(与 patch 里声明的逐字相同,否则 loader 认不出)', () => {
|
|
25
|
+
assert.equal(CORDIS_NAME, 'dsh-router-ext-test')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** 最小 ctx:inject 立刻回调、emit 记下来。 */
|
|
29
|
+
function fakeCtx(table: RouterExtService): {
|
|
30
|
+
ctx: Parameters<typeof apply>[0]
|
|
31
|
+
events: string[]
|
|
32
|
+
} {
|
|
33
|
+
const events: string[] = []
|
|
34
|
+
return {
|
|
35
|
+
events,
|
|
36
|
+
ctx: {
|
|
37
|
+
inject: (deps: string[], cb: (sctx: unknown) => void) => {
|
|
38
|
+
cb({ get: (key: string) => (key === 'router.ext' ? table : undefined) })
|
|
39
|
+
},
|
|
40
|
+
emit: (event: string) => { events.push(event) },
|
|
41
|
+
} as unknown as Parameters<typeof apply>[0],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', () => {
|
|
46
|
+
const table: RouterExtService = {}
|
|
47
|
+
const { ctx, events } = fakeCtx(table)
|
|
48
|
+
apply(ctx)
|
|
49
|
+
assert.equal(table[EXT_TEST_ID]?.name, '插件自检')
|
|
50
|
+
assert.deepEqual(events, [], '核心持有同一个 live 对象且每次请求现读,不需要广播事件')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('幂等:同一个 id 已在表里时不顶掉别人的登记', () => {
|
|
54
|
+
const table: RouterExtService = {}
|
|
55
|
+
const { ctx } = fakeCtx(table)
|
|
56
|
+
apply(ctx)
|
|
57
|
+
const first = table[EXT_TEST_ID]
|
|
58
|
+
apply(ctx)
|
|
59
|
+
assert.equal(table[EXT_TEST_ID], first, '重复登记不该换成一个新对象')
|
|
60
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自检扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
|
|
3
|
+
* 原生「包含的组件」里的那一行。
|
|
4
|
+
*
|
|
5
|
+
* 壳存在的理由与内置供应商那三层一样:`plugin.ts` 已经把 `name` 用作**面板显示名**
|
|
6
|
+
* (扩展契约的字段,所有扩展都这么写),而 cordis 的插件身份也要一个 `name`,两者抢
|
|
7
|
+
* 同一个名字。所以行 = 这一层壳(cordis 身份 + 登记),实现 = `plugin.ts`。
|
|
8
|
+
*
|
|
9
|
+
* **默认关闭**(patch 里 `disabled: true`):行关闭时 loader 根本不 import 这个模块,
|
|
10
|
+
* 扩展也就没进 `router.ext` 表 —— 所以「打开才出现这张卡片」是天然的,不需要额外的
|
|
11
|
+
* 状态位。用户在这一行打开后,卡片出现在插件页的「路由组件 → 扩展」组与面板的
|
|
12
|
+
* 「扩展」页。
|
|
13
|
+
*
|
|
14
|
+
* 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
|
|
15
|
+
* (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
|
|
16
|
+
*/
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import { currentExts, currentExtStore } from '../ext/contract.ts'
|
|
19
|
+
import { createTestExt, EXT_TEST_ID } from './plugin.ts'
|
|
20
|
+
|
|
21
|
+
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
22
|
+
export const name = 'dsh-router-ext-test'
|
|
23
|
+
|
|
24
|
+
/** 登记本扩展。幂等:同一个 id 已在表里就直接返回,不顶掉别人的登记。 */
|
|
25
|
+
export function apply(ctx: Context): void {
|
|
26
|
+
ctx.inject(['router.ext'], (sctx) => {
|
|
27
|
+
const table = currentExts(sctx)
|
|
28
|
+
if (table === undefined) return
|
|
29
|
+
if (table[EXT_TEST_ID] !== undefined) return
|
|
30
|
+
table[EXT_TEST_ID] = createTestExt()
|
|
31
|
+
currentExtStore(sctx)?.setEnabled(EXT_TEST_ID, true)
|
|
32
|
+
// 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
|
|
33
|
+
// 现读这张表 —— 所以往里 append 就够了,不需要(也不存在)广播事件。
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自检扩展(`router.ext` 侧的实现)—— 声明与状态都在这里,**不做任何拦截**。
|
|
3
|
+
*
|
|
4
|
+
* 它和 rtk 那类扩展的区别要写清楚:本扩展**不挂任何监听、不改写任何命令**,
|
|
5
|
+
* 唯一作用是让用户在官方插件页里打开一块自检面板(选供应商 / 模型 / 连接,跑一次
|
|
6
|
+
* 真实的访问测试并看到底报了什么)。所以它复用 `router.ext` 这条通道只是为了拿到
|
|
7
|
+
* **发现 + 开关**两件事,而不是为了拦截。
|
|
8
|
+
*
|
|
9
|
+
* 面板本身在浏览器侧(`src/client/ExtTestPanel.tsx`),通过原生行的
|
|
10
|
+
* `plugins.row.config` 座位挂在「包含的组件」里那一行的详情页上;这里只负责让它在
|
|
11
|
+
* 列表里有个身份、以及报出运行时就绪状态。
|
|
12
|
+
*/
|
|
13
|
+
import type { RouterExt } from '../ext/contract.ts'
|
|
14
|
+
|
|
15
|
+
/** 扩展开关表里的注册键(也是 URL 面板读它的 id)。 */
|
|
16
|
+
export const EXT_TEST_ID = 'test'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 构造这个扩展器。
|
|
20
|
+
*
|
|
21
|
+
* `getState()` 恒为就绪:自检能力不依赖外部二进制(不像 rtk 需要本机装 rtk),
|
|
22
|
+
* 「能不能测」取决于用户选的供应商/模型/连接,不该由扩展提前否决 —— 真跑一次
|
|
23
|
+
* 才知道,那时错误信息比一个「未就绪」有用得多。
|
|
24
|
+
*/
|
|
25
|
+
export function createTestExt(): RouterExt {
|
|
26
|
+
return {
|
|
27
|
+
id: EXT_TEST_ID,
|
|
28
|
+
name: '插件自检',
|
|
29
|
+
description: '选供应商、模型与连接跑一次真实访问测试;或对外部供应商插件做契约体检,逐个成员报告实现与可用情况',
|
|
30
|
+
// 随核心分发 -> 插件页的原生「包含的组件」里有它一行,自绘节里不再重复列。
|
|
31
|
+
source: 'builtin' as const,
|
|
32
|
+
getState: () => ({ ready: true }),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网关端点按**宿主实际监听端口**解析 —— issue #9 的闸门。
|
|
3
|
+
*
|
|
4
|
+
* ## 这条闸门拦的是什么
|
|
5
|
+
*
|
|
6
|
+
* `/v1/*` 是注册在**宿主** webServer 上的路由,端口由启动通道给:`dsh web` 默认
|
|
7
|
+
* 3080,桌面端官方通道、以及 `port: 0`(OS 分配)都不是。adapter 曾把
|
|
8
|
+
* `http://localhost:3080/v1` 写死,于是那些宿主上 Router 每一轮对话都是
|
|
9
|
+
* `dsh-router upstream call failed: fetch failed`,而 `/v1` 明明就在本机跑着。
|
|
10
|
+
*
|
|
11
|
+
* 判据刻意用**真服务器 + OS 分配端口**而不是打桩 fetch:打桩只能证明字符串拼对了,
|
|
12
|
+
* 证明不了「非 3080 端口上请求真的落得到」(这正是当初漏掉这条 bug 的原因)。
|
|
13
|
+
* `listen(0)` 拿到的端口几乎不可能是 3080,于是「用 3080 也能过」的假修法在这条
|
|
14
|
+
* 闸门下会当场变红。
|
|
15
|
+
*/
|
|
16
|
+
import test from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { createServer } from 'node:http'
|
|
19
|
+
import type { AddressInfo } from 'node:net'
|
|
20
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { RouterAdapter } from './llm/adapter.ts'
|
|
23
|
+
import { loopbackBaseURL } from './host-base-url.ts'
|
|
24
|
+
|
|
25
|
+
/** 起一个只回一条最小 SSE 的真服务器,回报 OS 分配的端口与收到的路径。 */
|
|
26
|
+
async function serveSse(): Promise<{ port: number; hits: string[]; close: () => Promise<void> }> {
|
|
27
|
+
const hits: string[] = []
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
hits.push(req.url ?? '')
|
|
30
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
31
|
+
res.end(`data: ${JSON.stringify({ choices: [{ delta: { content: 'ok' } }] })}\n\ndata: [DONE]\n\n`)
|
|
32
|
+
})
|
|
33
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
34
|
+
const { port } = server.address() as AddressInfo
|
|
35
|
+
return { port, hits, close: () => new Promise<void>((resolve) => server.close(() => resolve())) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 跑完一次 adapter 流,返回收到的块类型。 */
|
|
39
|
+
async function drain(adapter: RouterAdapter): Promise<string[]> {
|
|
40
|
+
const types: string[] = []
|
|
41
|
+
for await (const c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(5000) } as never)) {
|
|
42
|
+
types.push(c.type)
|
|
43
|
+
}
|
|
44
|
+
return types
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('端点:宿主端口不是 3080 时,请求照样落得到(issue #9 的原始现场)', async () => {
|
|
48
|
+
const up = await serveSse()
|
|
49
|
+
try {
|
|
50
|
+
assert.notEqual(up.port, 3080, '这条用例的判据就是「端口不是 3080」')
|
|
51
|
+
const adapter = new RouterAdapter(loopbackBaseURL(() => up.port), { comboModels: async () => [] })
|
|
52
|
+
const types = await drain(adapter)
|
|
53
|
+
assert.deepEqual(up.hits, ['/v1/chat/completions'], `请求应打到宿主 /v1,实际打到:${JSON.stringify(up.hits)}`)
|
|
54
|
+
assert.ok(types.includes('text-delta'), `应真的收到上游内容,实际块类型:${JSON.stringify(types)}`)
|
|
55
|
+
} finally {
|
|
56
|
+
await up.close()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('端点:端口每次请求现算(listen 前读不到、宿主重启换端口都不缓存)', async () => {
|
|
61
|
+
const first = await serveSse()
|
|
62
|
+
const second = await serveSse()
|
|
63
|
+
try {
|
|
64
|
+
let port: number | undefined = first.port
|
|
65
|
+
let reads = 0
|
|
66
|
+
const adapter = new RouterAdapter(
|
|
67
|
+
loopbackBaseURL(() => {
|
|
68
|
+
reads += 1
|
|
69
|
+
return port
|
|
70
|
+
}),
|
|
71
|
+
{ comboModels: async () => [] },
|
|
72
|
+
)
|
|
73
|
+
await drain(adapter)
|
|
74
|
+
port = second.port // 宿主换了端口(重启 / 重新 listen)
|
|
75
|
+
await drain(adapter)
|
|
76
|
+
assert.deepEqual(first.hits, ['/v1/chat/completions'], '第一次应打在第一个端口')
|
|
77
|
+
assert.deepEqual(second.hits, ['/v1/chat/completions'], '端口变了之后应改打新端口(不能缓存第一次的值)')
|
|
78
|
+
assert.ok(reads >= 2, `端口应每请求现读,实际只读了 ${reads} 次`)
|
|
79
|
+
} finally {
|
|
80
|
+
await first.close()
|
|
81
|
+
await second.close()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('端点:端口读不到时报出原因,不回退 3080 硬猜', async () => {
|
|
86
|
+
const adapter = new RouterAdapter(loopbackBaseURL(() => undefined), { comboModels: async () => [] })
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() => drain(adapter),
|
|
89
|
+
(err: Error) => {
|
|
90
|
+
assert.match(err.message, /webServer/, `报错必须带原因,实际:${err.message}`)
|
|
91
|
+
assert.equal((err as { code?: string }).code, 'TRANSPORT', '应是可重试的传输类错误')
|
|
92
|
+
return true
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('端点:常量形态的 baseURL 仍然可用(测试与显式传入的老调用方)', () => {
|
|
98
|
+
assert.equal(loopbackBaseURL(() => 19387)(), 'http://127.0.0.1:19387/v1')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('源码:非测试代码里不得再出现写死的 3080 端点', () => {
|
|
102
|
+
// 防复发闸门:这次的根因就是一行写死的字符串,只靠「端口现算」的实现测试拦不住
|
|
103
|
+
// 将来有人在别处再写一遍。判据只看**代码**(先剥掉注释)——注释里可以、也应该
|
|
104
|
+
// 写明「当年就是这里写死了 3080」。
|
|
105
|
+
const root = fileURLToPath(new URL('.', import.meta.url))
|
|
106
|
+
const offenders: string[] = []
|
|
107
|
+
for (const file of walk(root)) {
|
|
108
|
+
if (/(?:localhost|127\.0\.0\.1):3080/.test(stripComments(readFileSync(file, 'utf8')))) {
|
|
109
|
+
offenders.push(file.slice(root.length))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
assert.deepEqual(offenders, [], `这些文件里还有写死的 3080 端点(宿主端口随启动通道变):${offenders.join(', ')}`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
/** 去掉块注释与行注释,只留可执行代码。 */
|
|
116
|
+
function stripComments(text: string): string {
|
|
117
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 递归列出 src 下的 .ts 文件(跳过测试自身:测试里出现端口字面量是合法的)。 */
|
|
121
|
+
function walk(dir: string): string[] {
|
|
122
|
+
const out: string[] = []
|
|
123
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
+
const path = `${dir}${entry.name}`
|
|
125
|
+
if (entry.isDirectory()) out.push(...walk(`${path}/`))
|
|
126
|
+
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) out.push(path)
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网关端点地址(`http://127.0.0.1:<宿主端口>/v1`)的解析。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么端口不能写死
|
|
5
|
+
*
|
|
6
|
+
* dsh-router **自己不开端口**:`/v1/*` 是注册在**宿主** web server 上的路由
|
|
7
|
+
* (见 `index.ts` 的 `ctx.webServer.register`)。所以端点地址里的端口由启动通道
|
|
8
|
+
* 决定,而不是插件决定 —— `dsh web` 默认 3080,桌面端官方通道、以及配置
|
|
9
|
+
* `port: 0`(让 OS 分配)时都**不是** 3080。之前 adapter 把
|
|
10
|
+
* `http://localhost:3080/v1` 写死在构造参数里,于是非 3080 的宿主上 Router
|
|
11
|
+
* 的每一轮对话都是 `dsh-router upstream call failed: fetch failed`(issue #9)。
|
|
12
|
+
*
|
|
13
|
+
* ## 为什么返回函数而不是字符串
|
|
14
|
+
*
|
|
15
|
+
* `webServer.port` 是**监听之后**才有值的 getter(listen 前是 `undefined`),
|
|
16
|
+
* 且宿主重启/换端口后它会变。所以这里返回函数、**每次请求现算**,不在注册时
|
|
17
|
+
* 算一次 —— 注册发生在启动早期,那时算只会把「拿不到端口」固化成常量。
|
|
18
|
+
*
|
|
19
|
+
* ## 拿不到端口时为什么抛,而不是回退 3080
|
|
20
|
+
*
|
|
21
|
+
* 回退等于把同一个 bug 换种写法:端口读不到时宿主的路由也没在服务,回退到 3080
|
|
22
|
+
* 只会重新得到一句无从查证的 `fetch failed`。这里抛明确的话,让它出现在
|
|
23
|
+
* `LlmError` 消息里(adapter 在 fetch 同一个 try 里调它),报错自带原因。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 回环地址:宿主即使监听 `0.0.0.0`(LAN 模式),走 127.0.0.1 也通。 */
|
|
27
|
+
const LOOPBACK_HOST = '127.0.0.1'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 造一个「每次调用现读宿主端口」的端点解析器。
|
|
31
|
+
*
|
|
32
|
+
* @param readPort 读宿主监听端口(`webServer.port`)。取不到时抛错而不是猜。
|
|
33
|
+
* @param path 端点路径前缀,默认 `/v1`。
|
|
34
|
+
*/
|
|
35
|
+
export function loopbackBaseURL(readPort: () => number | undefined, path: string = '/v1'): () => string {
|
|
36
|
+
return () => {
|
|
37
|
+
const port = readPort()
|
|
38
|
+
if (port === undefined) {
|
|
39
|
+
throw new Error('dsh-router: 宿主 webServer 端口未就绪(webServer.port 读不到),无法定位 /v1 端点')
|
|
40
|
+
}
|
|
41
|
+
return `http://${LOOPBACK_HOST}:${String(port)}${path}`
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Host half of dsh-router — a simplified 9router.
|
|
3
3
|
*
|
|
4
4
|
* dsh-router IS the router: it exposes an OpenAI-compatible `/v1/*` endpoint
|
|
5
|
-
* on the DSH web server (http://
|
|
5
|
+
* on the DSH web server (http://127.0.0.1:<宿主监听端口>/v1 — 端口随启动通道
|
|
6
|
+
* 变,`dsh web` 默认 3080,见 host-base-url.ts), and routes requests to
|
|
6
7
|
* internal suppliers.
|
|
7
8
|
*
|
|
8
9
|
* Routes:
|
|
@@ -36,12 +37,13 @@ import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
|
36
37
|
import { Router } from './router/index.ts'
|
|
37
38
|
import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
|
|
38
39
|
import { KeysStore } from './keys.ts'
|
|
39
|
-
import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
40
|
+
import { loadSuppliers, supplierWins, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
40
41
|
import { supplierRoutes } from './suppliers/registry.ts'
|
|
41
|
-
import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
42
|
+
import type { SupplierEnv, SupplierModule, SupplierRegistry } from './suppliers/contract.ts'
|
|
42
43
|
import { SupplierConfigStore } from './supplier-config.ts'
|
|
43
44
|
import { CredentialStore } from './credential-store.ts'
|
|
44
45
|
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
46
|
+
import { loopbackBaseURL } from './host-base-url.ts'
|
|
45
47
|
import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
|
|
46
48
|
import { ExtStore } from './ext/store.ts'
|
|
47
49
|
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
@@ -91,6 +93,12 @@ interface WebServerRoute {
|
|
|
91
93
|
}
|
|
92
94
|
interface WebServer {
|
|
93
95
|
register: (route: WebServerRoute) => () => void
|
|
96
|
+
/**
|
|
97
|
+
* 宿主**实际监听**的端口(配置 port=0 时是 OS 分配的那个)。
|
|
98
|
+
* listen 之前为 undefined —— adapter 的端点按请求现读它,见 host-base-url.ts
|
|
99
|
+
* 与 issue #9。
|
|
100
|
+
*/
|
|
101
|
+
readonly port?: number
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/** Minimal shape of the llm service faces used here (设置-模型 提供方/模型目录). */
|
|
@@ -217,7 +225,7 @@ export function apply(rawContext: unknown): void {
|
|
|
217
225
|
disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
|
|
218
226
|
}
|
|
219
227
|
|
|
220
|
-
// ----
|
|
228
|
+
// ---- 供应商注册表:行(内置)+ 用户目录 js + 外部插件供应商 ----
|
|
221
229
|
const loadedSuppliers: LoadedSupplier[] = []
|
|
222
230
|
/** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
|
|
223
231
|
const supplierDisposers = new Map<string, Array<() => void>>()
|
|
@@ -261,23 +269,38 @@ export function apply(rawContext: unknown): void {
|
|
|
261
269
|
|
|
262
270
|
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
263
271
|
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
272
|
+
// 同 id 已有供应商:只有「级别更高」的那个能顶掉它(用户目录 js > 内置行 >
|
|
273
|
+
// 外部插件)。级别不高不低就直接丢,别把先到的那份卸了又装回来。
|
|
274
|
+
const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id)
|
|
275
|
+
if (existing !== undefined) {
|
|
276
|
+
if (!supplierWins(loaded.source, existing.source)) {
|
|
277
|
+
log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`)
|
|
281
|
+
unregisterSupplier(loaded.supplier.id)
|
|
282
|
+
}
|
|
264
283
|
loadedSuppliers.push(loaded)
|
|
265
284
|
router.add(loaded.supplier)
|
|
266
285
|
registerSupplierRoutes(loaded)
|
|
267
286
|
}
|
|
268
287
|
/** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
|
|
269
288
|
let externalSupplierIds: string[] = []
|
|
270
|
-
const loadExternal = (suppliers:
|
|
289
|
+
const loadExternal = (suppliers: SupplierRegistry): void => {
|
|
271
290
|
for (const [sid, factory] of Object.entries(suppliers)) {
|
|
272
291
|
if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
|
|
273
292
|
try {
|
|
274
293
|
const module = factory({ dataDir, log, store, credentials })
|
|
275
|
-
|
|
294
|
+
// 工厂上的 source 标签决定 `/health` 怎么报它:内置行标 'builtin',独立
|
|
295
|
+
// 安装的供应商插件不标(= external)。面板按这个值分「内置 / 插件」两组,
|
|
296
|
+
// 标错就把随核心分发的供应商混进插件组了。
|
|
297
|
+
const kind = factory.source === 'builtin' ? 'builtin' : 'external'
|
|
298
|
+
const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`, kind)
|
|
276
299
|
registerLoaded(loaded)
|
|
277
300
|
externalSupplierIds.push(loaded.supplier.id)
|
|
278
|
-
log(
|
|
301
|
+
log(`${kind} supplier loaded: ${sid}`)
|
|
279
302
|
} catch (err) {
|
|
280
|
-
ctx.logger.warn(`[dsh-router]
|
|
303
|
+
ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${(err as Error).message}`)
|
|
281
304
|
}
|
|
282
305
|
}
|
|
283
306
|
}
|
|
@@ -305,7 +328,6 @@ export function apply(rawContext: unknown): void {
|
|
|
305
328
|
// 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
|
|
306
329
|
const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
|
|
307
330
|
const { suppliers, errors } = await loadSuppliers({
|
|
308
|
-
builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
|
|
309
331
|
userDir,
|
|
310
332
|
dataDir,
|
|
311
333
|
store,
|
|
@@ -394,6 +416,8 @@ export function apply(rawContext: unknown): void {
|
|
|
394
416
|
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
395
417
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
396
418
|
source: loaded?.source ?? 'external',
|
|
419
|
+
// 开关状态:关掉的供应商**仍在列表里**(关掉后要能再开),只是不参与路由。
|
|
420
|
+
enabled: s.enabled,
|
|
397
421
|
}
|
|
398
422
|
}),
|
|
399
423
|
})
|
|
@@ -610,6 +634,8 @@ export function apply(rawContext: unknown): void {
|
|
|
610
634
|
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
611
635
|
enabled: extStore.isEnabled(e.id),
|
|
612
636
|
ready: st?.ready === true,
|
|
637
|
+
// 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
|
|
638
|
+
...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
|
|
613
639
|
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
614
640
|
}
|
|
615
641
|
})
|
|
@@ -724,12 +750,21 @@ export function apply(rawContext: unknown): void {
|
|
|
724
750
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
725
751
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
726
752
|
// 是宿主注入的 service,插件不直接 import 它,只依赖 duck-typed 切面)。
|
|
727
|
-
|
|
753
|
+
// 端点**按宿主实际监听端口现算**:`/v1` 路由注册在宿主 webServer 上,端口由
|
|
754
|
+
// 启动通道给(dsh web 默认 3080,桌面端/port=0 时不是),写死 3080 会让
|
|
755
|
+
// Router 在那些宿主上每一轮都 `fetch failed`(issue #9)。
|
|
756
|
+
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter(
|
|
757
|
+
loopbackBaseURL(() => ctx.webServer?.port), {
|
|
728
758
|
comboModels: async () => (await router.combos()).map((c) => {
|
|
729
759
|
const w = router.comboContextWindow(c)
|
|
730
760
|
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
|
731
761
|
}),
|
|
732
|
-
},
|
|
762
|
+
},
|
|
763
|
+
() => ctx.get('attachments') as RouterAttachmentStore | undefined,
|
|
764
|
+
undefined,
|
|
765
|
+
// 第五个参数 = 降级诊断出口:图片读不到会降级成占位文本,原因必须出口
|
|
766
|
+
// (曾经那条路径是空 catch,issue #8 的回归因此静默了两个内测版本)。
|
|
767
|
+
(msg) => ctx.logger.warn(`[dsh-router] ${msg}`))))
|
|
733
768
|
log('llm provider (Router) + discovery + adapter registered ok')
|
|
734
769
|
// 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
|
|
735
770
|
// 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -683,13 +683,48 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
|
|
|
683
683
|
|
|
684
684
|
/* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
|
|
685
685
|
|
|
686
|
-
/**
|
|
686
|
+
/**
|
|
687
|
+
* 假附件 store:**按宿主的方式校验请求目标**,返回指定 mediaType + bytes 的请求版本。
|
|
688
|
+
*
|
|
689
|
+
* 为什么必须校验:宿主 0.1.6 起把请求目标从 `ImageRequestPolicy`(maxPixels +
|
|
690
|
+
* maxBytes)换成 `ImageRequestTarget`(width + height + maxBytes),字段不匹配就在
|
|
691
|
+
* `validateTarget()` 抛错。之前这个假 store **两个参数都不看**(`async () => ({...})`),
|
|
692
|
+
* 于是插件侧传错字段没有任何测试会发现 —— 生产里图片静默降级成占位文本,跨两个
|
|
693
|
+
* 内测版本没人察觉(issue #8)。现在假 store 照宿主那样校验,字段写错会当场红。
|
|
694
|
+
*/
|
|
687
695
|
function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
|
|
688
|
-
readImageRequest: (
|
|
696
|
+
readImageRequest: (
|
|
697
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
698
|
+
target: { width: number; height: number; maxBytes: number },
|
|
699
|
+
) => Promise<{ mediaType: string; data: Uint8Array }>
|
|
700
|
+
/** 最近一次收到的请求目标,供断言检查。 */
|
|
701
|
+
lastTarget?: { width: number; height: number; maxBytes: number }
|
|
689
702
|
} {
|
|
690
|
-
|
|
691
|
-
|
|
703
|
+
// 用闭包记录而不是 `this`:对象字面量里的箭头函数拿不到宿主对象。
|
|
704
|
+
// 显式标注类型,否则 `store.lastTarget = …` 会被推断出的窄类型挡下。
|
|
705
|
+
const store: {
|
|
706
|
+
readImageRequest: (
|
|
707
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
708
|
+
target: { width: number; height: number; maxBytes: number },
|
|
709
|
+
) => Promise<{ mediaType: string; data: Uint8Array }>
|
|
710
|
+
lastTarget?: { width: number; height: number; maxBytes: number }
|
|
711
|
+
} = {
|
|
712
|
+
readImageRequest: async (_ref: unknown, target: { width: number; height: number; maxBytes: number }) => {
|
|
713
|
+
// 与宿主 validateTarget **逐字同规则**:它查的是 width/height/maxBytes 这三个
|
|
714
|
+
// 键**存在且为正整数**。注意不能写成「遍历传进来的键校验」——那样漏掉整个
|
|
715
|
+
// 键(0.1.5 的 maxPixels)反而能过,闸门就成了摆设(实测:这么写时把实现改回
|
|
716
|
+
// maxPixels,45 条测试全绿)。
|
|
717
|
+
for (const key of ['width', 'height', 'maxBytes']) {
|
|
718
|
+
const value = (target as unknown as Record<string, unknown>)[key]
|
|
719
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
720
|
+
throw new Error(`Image request ${key} must be a positive integer.`)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
store.lastTarget = target
|
|
724
|
+
return { mediaType, data }
|
|
725
|
+
},
|
|
692
726
|
}
|
|
727
|
+
return store
|
|
693
728
|
}
|
|
694
729
|
|
|
695
730
|
/** 用给定 attachments 跑一次 stream(),返回上游收到的 body。 */
|
|
@@ -710,6 +745,68 @@ async function captureBodyWith(options: Record<string, unknown>, attachments?: u
|
|
|
710
745
|
return captured
|
|
711
746
|
}
|
|
712
747
|
|
|
748
|
+
/* ---------------- 请求目标:形状与像素预算(issue #8 的回归闸门) ---------------- */
|
|
749
|
+
|
|
750
|
+
test('目标:请求目标是 0.1.6+ 的 target 形状(width/height/maxBytes),没有 0.1.5 的 maxPixels', async () => {
|
|
751
|
+
const attachments = fakeAttachments()
|
|
752
|
+
await captureBodyWith({
|
|
753
|
+
messages: [{ role: 'user', content: [
|
|
754
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:aa', mediaType: 'image/png', bytes: 11, width: 1000, height: 338 } },
|
|
755
|
+
] }],
|
|
756
|
+
}, attachments)
|
|
757
|
+
const target = attachments.lastTarget
|
|
758
|
+
assert.ok(target !== undefined, '必须真的调过 readImageRequest')
|
|
759
|
+
assert.deepEqual(Object.keys(target).sort(), ['height', 'maxBytes', 'width'],
|
|
760
|
+
'键就是宿主 validateTarget 查的那三个;多一个少一个都会被宿主拒')
|
|
761
|
+
assert.equal('maxPixels' in (target as unknown as Record<string, unknown>), false,
|
|
762
|
+
'0.1.5 时代的 maxPixels 不再被宿主接受,带上它等于没传 width/height')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
test('目标:大图按 64e4 像素预算等比投影(不放大;小图原样)', async () => {
|
|
766
|
+
const big = fakeAttachments()
|
|
767
|
+
await captureBodyWith({
|
|
768
|
+
messages: [{ role: 'user', content: [
|
|
769
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:bb', mediaType: 'image/png', bytes: 11, width: 4000, height: 3000 } },
|
|
770
|
+
] }],
|
|
771
|
+
}, big)
|
|
772
|
+
const t = big.lastTarget
|
|
773
|
+
assert.ok(t !== undefined)
|
|
774
|
+
// 12MP 投影到 ~64e4 像素内,且不超过预算
|
|
775
|
+
assert.ok(t.width * t.height <= 64e4, `投影后仍在预算内,实际 ${t.width}x${t.height}`)
|
|
776
|
+
assert.ok(t.width < 4000 && t.height < 3000, '大图要缩')
|
|
777
|
+
const small = fakeAttachments()
|
|
778
|
+
await captureBodyWith({
|
|
779
|
+
messages: [{ role: 'user', content: [
|
|
780
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:cc', mediaType: 'image/png', bytes: 11, width: 4, height: 2 } },
|
|
781
|
+
] }],
|
|
782
|
+
}, small)
|
|
783
|
+
assert.deepEqual(small.lastTarget, { width: 4, height: 2, maxBytes: 1024 * 1024 }, '预算内的小图不放大')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test('降级留痕:读图失败会写一条 log,不再静默(issue #8 为什么能静默两个版本)', async () => {
|
|
787
|
+
const lines: string[] = []
|
|
788
|
+
const boom = {
|
|
789
|
+
readImageRequest: async () => { throw new Error('Image request width must be a positive integer.') },
|
|
790
|
+
}
|
|
791
|
+
const adapter = new RouterAdapter(
|
|
792
|
+
'http://x',
|
|
793
|
+
{ comboModels: async () => [] },
|
|
794
|
+
() => boom as never,
|
|
795
|
+
undefined,
|
|
796
|
+
(msg) => lines.push(msg),
|
|
797
|
+
)
|
|
798
|
+
globalThis.fetch = (async () => new Response('data: [DONE]\n\n', { status: 200 })) as typeof fetch
|
|
799
|
+
for await (const _c of adapter.stream({
|
|
800
|
+
model: 'm',
|
|
801
|
+
messages: [{ role: 'user', content: [
|
|
802
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:dd', mediaType: 'image/png', bytes: 11, width: 4, height: 2 } },
|
|
803
|
+
] }],
|
|
804
|
+
signal: AbortSignal.timeout(3000),
|
|
805
|
+
} as never)) { void _c }
|
|
806
|
+
assert.equal(lines.length, 1, '降级必须留一条痕,否则问题又会是「静默」')
|
|
807
|
+
assert.match(lines[0] ?? '', /width must be a positive integer/, 'log 要带上真实原因')
|
|
808
|
+
})
|
|
809
|
+
|
|
713
810
|
test('wire:图片块被序列化成 OpenAI image_url base64 part(不再静默丢图)', async () => {
|
|
714
811
|
const ref = {
|
|
715
812
|
attachmentId: 'sha256:abcdef1234567890',
|