dsh-router-core 0.4.2 → 0.4.4

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.
Files changed (51) hide show
  1. package/README.md +9 -10
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +64 -2
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +286 -127
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +64 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-version.d.ts +7 -8
  21. package/lib/types/index.d.ts +1 -1
  22. package/lib/types/shared.d.ts +33 -0
  23. package/package.json +17 -7
  24. package/src/client/EndpointTab.tsx +15 -18
  25. package/src/client/ExtDetail.tsx +552 -9
  26. package/src/client/index.tsx +7 -28
  27. package/src/client/router.css +132 -62
  28. package/src/ext/contract.ts +111 -0
  29. package/src/ext-prompt/assembly.test.ts +79 -0
  30. package/src/ext-prompt/content.ts +181 -0
  31. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  32. package/src/ext-prompt/index.ts +104 -0
  33. package/src/ext-prompt/locale/en.json +6 -0
  34. package/src/ext-prompt/locale/zh.json +6 -0
  35. package/src/ext-prompt/logo.svg +14 -0
  36. package/src/ext-prompt/logo.ts +18 -0
  37. package/src/ext-prompt/mount.test.ts +95 -0
  38. package/src/ext-prompt/mount.ts +79 -0
  39. package/src/ext-prompt/plugin.ts +223 -0
  40. package/src/ext-prompt/render.test.ts +256 -0
  41. package/src/ext-prompt/render.ts +216 -0
  42. package/src/ext-prompt/row.json +5 -0
  43. package/src/ext-route.test.ts +78 -0
  44. package/src/host-version.test.ts +21 -28
  45. package/src/host-version.ts +39 -39
  46. package/src/index.ts +216 -94
  47. package/src/llm/adapter.test.ts +19 -42
  48. package/src/llm/adapter.ts +20 -47
  49. package/src/shared.ts +34 -0
  50. package/src/suppliers/registry.test.ts +56 -0
  51. package/src/suppliers/registry.ts +40 -10
package/src/shared.ts CHANGED
@@ -164,6 +164,40 @@ export interface RouterExtItem {
164
164
  * 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
165
165
  */
166
166
  source?: 'builtin'
167
+ /**
168
+ * 扩展自带的子开关(如准则的每一条)。**由扩展自报**,核心只搬运。
169
+ *
170
+ * ⚠️ 与 `enabled`(总开关)不同:子开关**不改变扩展是否被加载**,只改它
171
+ * 自己的行为。总开关关时这些子开关一并失效(面板上禁用,不隐藏)。
172
+ */
173
+ controls?: ExtControlItem[]
174
+ }
175
+
176
+ /** `/router/api/ext` 里的一条子开关(与 `router.ext` 契约的 `ExtControl` 对应)。 */
177
+ export interface ExtControlItem {
178
+ /** 稳定 id(持久化键;改名会丢用户已保存的选择)。 */
179
+ id: string
180
+ title: string
181
+ /** 当前是否开启(扩展自算:默认值 + 用户已存选择)。 */
182
+ on: boolean
183
+ /** 一句话说明(面板副行,可选)。 */
184
+ detail?: string
185
+ /** 原文(面板点开即可读到;不给出就是在盲切,见 `ExtControl.body`)。 */
186
+ body?: string
187
+ /** 文本可编辑(缺省 = 只读;见 `ExtControl.editable`)。 */
188
+ editable?: boolean
189
+ /**
190
+ * 自建条目(可删)还是内置(不可删、只有开关与还原)。
191
+ *
192
+ * ⚠️ **必填,不给可选**(2026-09-30 实测踩过):曾声明成 `custom?: boolean`,
193
+ * 而服务端只在 `custom === true` 时才带这个字段 ⇒ 内置条目拿到的是
194
+ * `undefined`,面板判 `c.custom === true` 为假,**恰好是对的** ——
195
+ * 但那条正确性建立在"缺省恰好等于 false"上,改任何一边的默认值都会翻转。
196
+ * ⇒ 让服务端**总是**给显式布尔,两边不必猜。
197
+ */
198
+ custom: boolean
199
+ /** 文本被改过(决定「还原」是否可点)。必填,理由同 `custom`。 */
200
+ overridden: boolean
167
201
  }
168
202
 
169
203
  /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
@@ -211,3 +211,59 @@ test('刷新:status() 抛错 → 500 带错误信息', async () => {
211
211
  assert.equal(body.ok, false)
212
212
  assert.match(body.error ?? '', /status boom/)
213
213
  })
214
+
215
+ /**
216
+ * 插件在 `listModels` 里抛错时,核心必须回 **JSON 500**。
217
+ *
218
+ * 为什么值得单锁:webServer 的兜底是 `res.writeHead(400); res.end()` —— **空
219
+ * body**。面板 `response.json()` 于是抛 "Unexpected end of JSON input",真实原因
220
+ * (上游 500 / token 失效…)全丢。插件契约要求「出错就抛」(docs/suppliers.md),
221
+ * 所以这层必须接住。traework 修掉 `catch {}` 之后这条才第一次真被走到。
222
+ */
223
+ test('models 路由:插件 listModels 抛错 → 500 + JSON body(不是空 400)', async () => {
224
+ for (const path of ['models', 'models/fetch']) {
225
+ const supplier = {
226
+ id: 'fake',
227
+ name: 'fake',
228
+ status: () => ({ id: 'fake', name: 'fake', accounts: [] }),
229
+ }
230
+ const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
231
+ // router.modelsOf 复刻核心语义:无旧值 → 把插件的错抛出来
232
+ const router = {
233
+ modelsOf: async (): Promise<never> => { throw new Error('models api returned empty list') },
234
+ invalidateModels: () => {},
235
+ }
236
+ const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
237
+ const route = routes.find((r) => r.path === `/router/api/suppliers/fake/${path}`)
238
+ assert.ok(route, `${path} 路由应注册`)
239
+ const sink = fakeRes()
240
+ await route!.handler({} as never, sink.res)
241
+ const body = sink.body as { ok: boolean; error?: string }
242
+ assert.equal(sink.status, 500, `${path}: 抛错该回 500`)
243
+ assert.equal(body.ok, false, `${path}: 必须是 JSON body,不能是空 body`)
244
+ assert.match(String(body.error), /empty list/, `${path}: 真实原因不能丢`)
245
+ }
246
+ })
247
+
248
+ test('models 路由:body 不是合法 JSON → 400(不是 500,也不是空 body)', async () => {
249
+ const supplier = { id: 'fake', name: 'fake', status: () => ({ id: 'fake', name: 'fake', accounts: [] }) }
250
+ const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
251
+ const router = { modelsOf: async () => [], invalidateModels: () => {} }
252
+ const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
253
+ const route = routes.find((r) => r.path === '/router/api/suppliers/fake/models/toggle')
254
+ assert.ok(route)
255
+ // 造一个会吐非法 JSON 的 req(readBody 读 data/end)
256
+ const req = {
257
+ on(event: string, cb: (chunk?: Buffer) => void) {
258
+ if (event === 'data') cb(Buffer.from('{ not json'))
259
+ if (event === 'end') cb()
260
+ return req
261
+ },
262
+ }
263
+ const sink = fakeRes()
264
+ await route!.handler(req as never, sink.res)
265
+ const body = sink.body as { ok: boolean; error?: string }
266
+ assert.equal(sink.status, 400, '客户端传坏 body 是 400,不是核心 500')
267
+ assert.equal(body.ok, false)
268
+ assert.ok(String(body.error).length > 0, '400 也要带原因,不能是空 body')
269
+ })
@@ -44,6 +44,36 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
44
44
  res.end(payload)
45
45
  }
46
46
 
47
+ /**
48
+ * 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
49
+ *
50
+ * 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
51
+ * body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
52
+ * 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
53
+ *
54
+ * 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
55
+ * 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
56
+ * 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
57
+ */
58
+ function guard(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>) {
59
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
60
+ try {
61
+ await handler(req, res)
62
+ } catch (err) {
63
+ // headers 已发出就没法再改状态码了(writeJson 是原子的,这里基本不会发生)
64
+ if (res.headersSent) {
65
+ res.end()
66
+ return
67
+ }
68
+ // body 不是合法 JSON 是客户端的事(400),不该报成核心 500。这些 handler 里
69
+ // 唯一的 SyntaxError 来源就是 `JSON.parse(await readBody(req))`。
70
+ // (改 guard 之前这种情况由 webServer 兜底成**空** 400,改后保住状态码又带上原因。)
71
+ const status = err instanceof SyntaxError ? 400 : 500
72
+ writeJson(res, status, { ok: false, error: (err as Error).message })
73
+ }
74
+ }
75
+ }
76
+
47
77
  async function readBody(req: IncomingMessage, limit = 64 << 10): Promise<string> {
48
78
  const chunks: Buffer[] = []
49
79
  let size = 0
@@ -109,10 +139,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
109
139
  routes.push({
110
140
  kind: 'exact',
111
141
  path: `${p}/models`,
112
- handler: async (_req, res) => {
142
+ handler: guard(async (_req, res) => {
113
143
  const models = await router.modelsOf(s.id)
114
144
  writeJson(res, 200, { ok: true, alias: s.getAlias(), models })
115
- },
145
+ }),
116
146
  })
117
147
 
118
148
  // ---- 通用: 供应商开关 ----
@@ -235,7 +265,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
235
265
  routes.push({
236
266
  kind: 'exact',
237
267
  path: `${p}/models/toggle`,
238
- handler: async (req, res) => {
268
+ handler: guard(async (req, res) => {
239
269
  const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
240
270
  const cfg = store.get(s.id)
241
271
  const models = await router.modelsOf(s.id)
@@ -246,14 +276,14 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
246
276
  store.setModelEnabled(s.id, body.id, !!body.enabled)
247
277
  router.invalidateModels(s.id)
248
278
  writeJson(res, 200, { ok: true })
249
- },
279
+ }),
250
280
  })
251
281
 
252
282
  // ---- 通用: models/add(自定义模型) ----
253
283
  routes.push({
254
284
  kind: 'exact',
255
285
  path: `${p}/models/add`,
256
- handler: async (req, res) => {
286
+ handler: guard(async (req, res) => {
257
287
  const body = JSON.parse(await readBody(req)) as { id?: string }
258
288
  const id = (body.id ?? '').trim()
259
289
  if (id === '') {
@@ -268,7 +298,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
268
298
  store.addCustomModel(s.id, id)
269
299
  router.invalidateModels(s.id)
270
300
  writeJson(res, 200, { ok: true })
271
- },
301
+ }),
272
302
  })
273
303
 
274
304
  // ---- 通用: models/remove(自定义模型) ----
@@ -292,13 +322,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
292
322
  routes.push({
293
323
  kind: 'exact',
294
324
  path: `${p}/models/bulk`,
295
- handler: async (req, res) => {
325
+ handler: guard(async (req, res) => {
296
326
  const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
297
327
  const models = await router.modelsOf(s.id)
298
328
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
299
329
  router.invalidateModels(s.id)
300
330
  writeJson(res, 200, { ok: true })
301
- },
331
+ }),
302
332
  })
303
333
 
304
334
  // ---- 通用: pool/order ----
@@ -324,10 +354,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
324
354
  routes.push({
325
355
  kind: 'exact',
326
356
  path: `${p}/models/fetch`,
327
- handler: async (_req, res) => {
357
+ handler: guard(async (_req, res) => {
328
358
  const models = await router.modelsOf(s.id, true)
329
359
  writeJson(res, 200, { ok: true, models })
330
- },
360
+ }),
331
361
  })
332
362
 
333
363
  // ---- 通用: 测试模型(核心统一走 chatOnce 路径,账号池回退/冷却自动生效) ----