dsh-router-core 0.4.1 → 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 +9 -5
- package/docs/ext.md +1 -1
- package/lib/index.js +49 -2
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/llm/adapter.d.ts +6 -1
- package/package.json +1 -1
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/index.ts +14 -2
- package/src/llm/adapter.ts +12 -3
package/README.md
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
<a href="#快速安装">快速安装</a> ·
|
|
15
15
|
<a href="#面板设置--路由">面板</a> ·
|
|
16
16
|
<a href="#agent-team-支持">Agent Team</a> ·
|
|
17
|
-
<a href="#api-端点openai-兼容
|
|
17
|
+
<a href="#api-端点openai-兼容v1">API 端点</a> ·
|
|
18
18
|
<a href="docs/suppliers.md">供应商开发</a> ·
|
|
19
19
|
<a href="docs/ext.md">扩展开发</a>
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
**插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进 DSH web,
|
|
23
|
-
|
|
23
|
+
在宿主 Web 服务的 `/v1` 上原生暴露 OpenAI 兼容端点(`http://127.0.0.1:<宿主页端口>/v1`;`dsh web` 默认 3080,面板「端点与密钥」页显示的是实际地址),把请求路由到内部供应商。
|
|
24
24
|
管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)。装好即用,
|
|
25
25
|
不用多开一个 9router、不用维护第二个端口、不用在网关和 DSH 之间搬配置。
|
|
26
26
|
|
|
@@ -160,11 +160,15 @@ team 测试就是多个会话交错发请求。dsh-router 原生适配这个场
|
|
|
160
160
|
为 DSH 模型目录里的 `router` provider 选项(设置 → 模型直接选组合名即可用),请求
|
|
161
161
|
按组合策略命中其中一个供应商模型;
|
|
162
162
|
- **端点与密钥** — 端点核心(无隧道/Tailscale):
|
|
163
|
-
- API 端点 URL(`
|
|
163
|
+
- API 端点 URL(面板按当前页面 origin 显示,可复制;端口跟着宿主走,`dsh web` 默认 3080);
|
|
164
164
|
- 鉴权设置 `requireApiKey` 开关;
|
|
165
165
|
- API Keys 管理:创建 / 启用切换 / 显示 / 复制 / 删除(持久化到 `data/keys.json`)。
|
|
166
166
|
|
|
167
|
-
## API 端点(OpenAI
|
|
167
|
+
## API 端点(OpenAI 兼容,`/v1`)
|
|
168
|
+
|
|
169
|
+
> 端口 = **宿主 Web 服务实际监听的端口**:`dsh web` 默认 3080,桌面端等通道由宿主
|
|
170
|
+
> 分配(不一定还是 3080)。下面按 3080 写;换通道时把端口换成面板「端点与密钥」
|
|
171
|
+
> 页里显示的那个即可。
|
|
168
172
|
|
|
169
173
|
```bash
|
|
170
174
|
# 模型列表
|
|
@@ -177,7 +181,7 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
177
181
|
```
|
|
178
182
|
|
|
179
183
|
任何支持 OpenAI 兼容 API 的工具(Claude Code、Cline、DSH 设置-模型 等)都可以把
|
|
180
|
-
`baseURL`
|
|
184
|
+
`baseURL` 指向面板「端点与密钥」页里显示的那个端点(`dsh web` 下即 `http://localhost:3080/v1`)。
|
|
181
185
|
|
|
182
186
|
**鉴权**:默认 `requireApiKey=false`,`/v1/*` 不要求鉴权(本地使用,与 9router 一致)。
|
|
183
187
|
在「端点与密钥」页开启「要求 API Key」后,请求必须带
|
package/docs/ext.md
CHANGED
|
@@ -223,7 +223,7 @@ tools.get('bash') // ❌ 只查全局视图,查不到 → 静默走
|
|
|
223
223
|
|
|
224
224
|
```bash
|
|
225
225
|
# 1) 看 API:扩展器 enabled / ready 是否都为 true
|
|
226
|
-
curl -s http://127.0.0.1:3080/router/api/ext
|
|
226
|
+
curl -s http://127.0.0.1:3080/router/api/ext # 端口 = 宿主页端口(面板可见),非 3080 的通道要换
|
|
227
227
|
|
|
228
228
|
# 2) 跑一条有等价改写的命令,看输出是否变成压缩格式
|
|
229
229
|
ls # 被改写时是 rtk 树形格式,不是原生列表
|
package/lib/index.js
CHANGED
|
@@ -3160,6 +3160,11 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
3160
3160
|
}
|
|
3161
3161
|
];
|
|
3162
3162
|
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
3163
|
+
/**
|
|
3164
|
+
* 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
|
|
3165
|
+
* 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
|
|
3166
|
+
* issue #9),见 `host-base-url.ts`。
|
|
3167
|
+
*/
|
|
3163
3168
|
baseURL;
|
|
3164
3169
|
source;
|
|
3165
3170
|
resolveAttachments;
|
|
@@ -3232,7 +3237,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
3232
3237
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3233
3238
|
let resp;
|
|
3234
3239
|
try {
|
|
3235
|
-
|
|
3240
|
+
const base = typeof this.baseURL === "function" ? this.baseURL() : this.baseURL;
|
|
3241
|
+
resp = await fetch(`${base}/chat/completions`, {
|
|
3236
3242
|
method: "POST",
|
|
3237
3243
|
headers: {
|
|
3238
3244
|
...attributionHeaders(),
|
|
@@ -4565,6 +4571,47 @@ function dshHome() {
|
|
|
4565
4571
|
return resolve(value);
|
|
4566
4572
|
}
|
|
4567
4573
|
//#endregion
|
|
4574
|
+
//#region src/host-base-url.ts
|
|
4575
|
+
/**
|
|
4576
|
+
* 网关端点地址(`http://127.0.0.1:<宿主端口>/v1`)的解析。
|
|
4577
|
+
*
|
|
4578
|
+
* ## 为什么端口不能写死
|
|
4579
|
+
*
|
|
4580
|
+
* dsh-router **自己不开端口**:`/v1/*` 是注册在**宿主** web server 上的路由
|
|
4581
|
+
* (见 `index.ts` 的 `ctx.webServer.register`)。所以端点地址里的端口由启动通道
|
|
4582
|
+
* 决定,而不是插件决定 —— `dsh web` 默认 3080,桌面端官方通道、以及配置
|
|
4583
|
+
* `port: 0`(让 OS 分配)时都**不是** 3080。之前 adapter 把
|
|
4584
|
+
* `http://localhost:3080/v1` 写死在构造参数里,于是非 3080 的宿主上 Router
|
|
4585
|
+
* 的每一轮对话都是 `dsh-router upstream call failed: fetch failed`(issue #9)。
|
|
4586
|
+
*
|
|
4587
|
+
* ## 为什么返回函数而不是字符串
|
|
4588
|
+
*
|
|
4589
|
+
* `webServer.port` 是**监听之后**才有值的 getter(listen 前是 `undefined`),
|
|
4590
|
+
* 且宿主重启/换端口后它会变。所以这里返回函数、**每次请求现算**,不在注册时
|
|
4591
|
+
* 算一次 —— 注册发生在启动早期,那时算只会把「拿不到端口」固化成常量。
|
|
4592
|
+
*
|
|
4593
|
+
* ## 拿不到端口时为什么抛,而不是回退 3080
|
|
4594
|
+
*
|
|
4595
|
+
* 回退等于把同一个 bug 换种写法:端口读不到时宿主的路由也没在服务,回退到 3080
|
|
4596
|
+
* 只会重新得到一句无从查证的 `fetch failed`。这里抛明确的话,让它出现在
|
|
4597
|
+
* `LlmError` 消息里(adapter 在 fetch 同一个 try 里调它),报错自带原因。
|
|
4598
|
+
*/
|
|
4599
|
+
/** 回环地址:宿主即使监听 `0.0.0.0`(LAN 模式),走 127.0.0.1 也通。 */
|
|
4600
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
4601
|
+
/**
|
|
4602
|
+
* 造一个「每次调用现读宿主端口」的端点解析器。
|
|
4603
|
+
*
|
|
4604
|
+
* @param readPort 读宿主监听端口(`webServer.port`)。取不到时抛错而不是猜。
|
|
4605
|
+
* @param path 端点路径前缀,默认 `/v1`。
|
|
4606
|
+
*/
|
|
4607
|
+
function loopbackBaseURL(readPort, path = "/v1") {
|
|
4608
|
+
return () => {
|
|
4609
|
+
const port = readPort();
|
|
4610
|
+
if (port === void 0) throw new Error("dsh-router: 宿主 webServer 端口未就绪(webServer.port 读不到),无法定位 /v1 端点");
|
|
4611
|
+
return `http://${LOOPBACK_HOST}:${String(port)}${path}`;
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
//#endregion
|
|
4568
4615
|
//#region src/host-version.ts
|
|
4569
4616
|
/**
|
|
4570
4617
|
* 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
|
|
@@ -5353,7 +5400,7 @@ function apply(rawContext) {
|
|
|
5353
5400
|
});
|
|
5354
5401
|
log(`Router settings section registered (${settingsNs})`);
|
|
5355
5402
|
});
|
|
5356
|
-
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(
|
|
5403
|
+
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(loopbackBaseURL(() => ctx.webServer?.port), { comboModels: async () => (await router.combos()).map((c) => {
|
|
5357
5404
|
const w = router.comboContextWindow(c);
|
|
5358
5405
|
return {
|
|
5359
5406
|
id: c.name,
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
* 造一个「每次调用现读宿主端口」的端点解析器。
|
|
27
|
+
*
|
|
28
|
+
* @param readPort 读宿主监听端口(`webServer.port`)。取不到时抛错而不是猜。
|
|
29
|
+
* @param path 端点路径前缀,默认 `/v1`。
|
|
30
|
+
*/
|
|
31
|
+
export declare function loopbackBaseURL(readPort: () => number | undefined, path?: string): () => string;
|
|
@@ -82,6 +82,11 @@ export interface RouterAdapterSource {
|
|
|
82
82
|
*/
|
|
83
83
|
export declare function translateSse(payloads: AsyncIterable<string> | Iterable<string>): AsyncGenerator<StreamChunk>;
|
|
84
84
|
export declare class RouterAdapter extends LlmAdapter {
|
|
85
|
+
/**
|
|
86
|
+
* 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
|
|
87
|
+
* 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
|
|
88
|
+
* issue #9),见 `host-base-url.ts`。
|
|
89
|
+
*/
|
|
85
90
|
private readonly baseURL;
|
|
86
91
|
private readonly source;
|
|
87
92
|
private readonly resolveAttachments;
|
|
@@ -95,7 +100,7 @@ export declare class RouterAdapter extends LlmAdapter {
|
|
|
95
100
|
* @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
|
|
96
101
|
* 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
|
|
97
102
|
*/
|
|
98
|
-
constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined, idleTimeoutMs?: number, log?: (msg: string) => void);
|
|
103
|
+
constructor(baseURL: string | (() => string), source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined, idleTimeoutMs?: number, log?: (msg: string) => void);
|
|
99
104
|
providerInfo(provider: string): {
|
|
100
105
|
id: string;
|
|
101
106
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -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:
|
|
@@ -42,6 +43,7 @@ import type { SupplierEnv, SupplierModule, SupplierRegistry } from './suppliers/
|
|
|
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 (设置-模型 提供方/模型目录). */
|
|
@@ -742,7 +750,11 @@ export function apply(rawContext: unknown): void {
|
|
|
742
750
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
743
751
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
744
752
|
// 是宿主注入的 service,插件不直接 import 它,只依赖 duck-typed 切面)。
|
|
745
|
-
|
|
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), {
|
|
746
758
|
comboModels: async () => (await router.combos()).map((c) => {
|
|
747
759
|
const w = router.comboContextWindow(c)
|
|
748
760
|
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
package/src/llm/adapter.ts
CHANGED
|
@@ -634,7 +634,12 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
|
|
|
634
634
|
]
|
|
635
635
|
|
|
636
636
|
export class RouterAdapter extends LlmAdapter {
|
|
637
|
-
|
|
637
|
+
/**
|
|
638
|
+
* 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
|
|
639
|
+
* 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
|
|
640
|
+
* issue #9),见 `host-base-url.ts`。
|
|
641
|
+
*/
|
|
642
|
+
private readonly baseURL: string | (() => string)
|
|
638
643
|
private readonly source: RouterAdapterSource
|
|
639
644
|
private readonly resolveAttachments: () => RouterAttachmentStore | undefined
|
|
640
645
|
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
@@ -649,7 +654,7 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
649
654
|
* 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
|
|
650
655
|
*/
|
|
651
656
|
constructor(
|
|
652
|
-
baseURL: string,
|
|
657
|
+
baseURL: string | (() => string),
|
|
653
658
|
source: RouterAdapterSource,
|
|
654
659
|
resolveAttachments?: () => RouterAttachmentStore | undefined,
|
|
655
660
|
idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
|
|
@@ -713,7 +718,11 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
713
718
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
714
719
|
let resp: Response
|
|
715
720
|
try {
|
|
716
|
-
|
|
721
|
+
// 端点现算:宿主监听端口在 listen 之后才有值、也会随宿主重启变(issue #9)。
|
|
722
|
+
// 解析器自己抛错时也落进下面这个 catch,于是「端口拿不到」是以
|
|
723
|
+
// LlmError 消息报出来的,而不是一句无从查证的 `fetch failed`。
|
|
724
|
+
const base = typeof this.baseURL === 'function' ? this.baseURL() : this.baseURL
|
|
725
|
+
resp = await fetch(`${base}/chat/completions`, {
|
|
717
726
|
method: 'POST',
|
|
718
727
|
// attributionHeaders() 是 dsh-llm 对 adapter 的硬契约:每个 provider
|
|
719
728
|
// 请求都必须带(LlmAdapter 类注释原话)。它给出
|