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 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-兼容3080v1">API 端点</a> ·
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
- 在 `http://localhost:3080/v1` 上原生暴露 OpenAI 兼容端点,把请求路由到内部供应商。
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(`http://localhost:3080/v1`,可复制);
163
+ - API 端点 URL(面板按当前页面 origin 显示,可复制;端口跟着宿主走,`dsh web` 默认 3080);
164
164
  - 鉴权设置 `requireApiKey` 开关;
165
165
  - API Keys 管理:创建 / 启用切换 / 显示 / 复制 / 删除(持久化到 `data/keys.json`)。
166
166
 
167
- ## API 端点(OpenAI 兼容,`:3080/v1`)
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` 指向 `http://localhost:3080/v1`。
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
- resp = await fetch(`${this.baseURL}/chat/completions`, {
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("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
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.1",
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://localhost:3080/v1), and routes requests to
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
- disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
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 } : {}) }
@@ -634,7 +634,12 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
634
634
  ]
635
635
 
636
636
  export class RouterAdapter extends LlmAdapter {
637
- private readonly baseURL: string
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
- resp = await fetch(`${this.baseURL}/chat/completions`, {
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 类注释原话)。它给出