dsh-router-core 0.3.21 → 0.4.1

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 (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
package/src/index.ts CHANGED
@@ -36,12 +36,13 @@ import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
36
36
  import { Router } from './router/index.ts'
37
37
  import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
38
38
  import { KeysStore } from './keys.ts'
39
- import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
39
+ import { loadSuppliers, supplierWins, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
40
40
  import { supplierRoutes } from './suppliers/registry.ts'
41
- import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
41
+ import type { SupplierEnv, SupplierModule, SupplierRegistry } from './suppliers/contract.ts'
42
42
  import { SupplierConfigStore } from './supplier-config.ts'
43
43
  import { CredentialStore } from './credential-store.ts'
44
44
  import { dataDirOf, profileDirOf } from './data-dir.ts'
45
+ import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
45
46
  import { ExtStore } from './ext/store.ts'
46
47
  import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
47
48
 
@@ -54,6 +55,34 @@ export const name = 'dsh-router-core'
54
55
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
55
56
  export const inject = ['webServer', 'llm']
56
57
 
58
+ /**
59
+ * `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
60
+ *
61
+ * **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
62
+ * adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
63
+ * 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
64
+ * 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
65
+ * (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
66
+ * 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
67
+ * 能力检测恰好就是**对**的判据。
68
+ *
69
+ * 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
70
+ * 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
71
+ * 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
72
+ * - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
73
+ * - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
74
+ * installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
75
+ */
76
+ const emptySchema = Schema.object({})
77
+ const hasVolatile = typeof (emptySchema as { volatile?: unknown }).volatile === 'function'
78
+
79
+ /**
80
+ * 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
81
+ * 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
82
+ * Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
83
+ */
84
+ export const Config = hasVolatile ? emptySchema.volatile() : emptySchema
85
+
57
86
  /** Minimal shape of the webServer service face used here. */
58
87
  interface WebServerRoute {
59
88
  kind: 'prefix' | 'exact'
@@ -96,12 +125,12 @@ interface Llm {
96
125
  }
97
126
 
98
127
  /**
99
- * 设置服务(`ctx.settings`,dsh-settings)的最小切面:本插件只用 `installSection`
100
- * 注册一个命名空间,让设置-模型页能列出 Router provider 卡片。dsh-settings 是
101
- * 宿主注入的 peer service,这里不深绑它的类型,只声明用到的这一个方法。
128
+ * 设置服务(`ctx.settings`,dsh-settings)的最小切面。0.1.5 用 `installSection`
129
+ * 注册命名空间;0.1.7 已移除该方法、改由插件导出的 Config 驱动 —— 故声明为可选,
130
+ * 调用处做能力检测(不是版本分支)。dsh-settings 是宿主注入的 peer service,不深绑类型。
102
131
  */
103
132
  interface SettingsServiceFace {
104
- installSection: (
133
+ installSection?: (
105
134
  owner: CordisContext,
106
135
  ns: string,
107
136
  schema: unknown,
@@ -156,6 +185,8 @@ export function apply(rawContext: unknown): void {
156
185
  logger: { info: (message: string) => void; warn: (message: string) => void }
157
186
  effect: (fn: () => () => void, label?: string) => void
158
187
  inject: (services: string[], callback: (sctx: unknown) => void) => void
188
+ /** cordis-plugin-loader 挂在 fiber 上的配置树锚点(tests 等环境可能没有)。 */
189
+ fiber?: { entry?: { options?: { id?: string } } }
159
190
  /** 配置树锚点 = profile 目录(宿主注入),用来把数据钉在 profile 里。 */
160
191
  baseUrl?: string
161
192
  }
@@ -166,6 +197,12 @@ export function apply(rawContext: unknown): void {
166
197
  const dataDir = dataDirOf(ctx.baseUrl)
167
198
  const stateFile = join(dataDir, 'state.json')
168
199
  log(`data dir: ${dataDir}`)
200
+ // 宿主版本探测:三处 0.1.5/0.1.7 兼容分叉共用这一个判据(徽章 / 设置命名空间 /
201
+ // adapter 的 tool-result 消息形态)。探测失败按老版本处理,不影响其它功能。
202
+ const hostVersion = detectHostVersion(ctx.baseUrl)
203
+ const host017Plus = isHost017Plus(ctx.baseUrl)
204
+ const lastHitDock = supportsLastHitDock(ctx.baseUrl)
205
+ log(`host version: ${hostVersion ?? '未知'} (>=0.1.7: ${host017Plus ? 'yes' : 'no'}, last-hit dock: ${lastHitDock ? 'on' : 'off'})`)
169
206
  const store = new SupplierConfigStore(stateFile)
170
207
  const credentials = new CredentialStore(join(dataDir, 'auths'))
171
208
  const router = new Router(stateFile, store, log)
@@ -180,7 +217,7 @@ export function apply(rawContext: unknown): void {
180
217
  disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
181
218
  }
182
219
 
183
- // ---- 供应商注册表:内置 + 用户自定义 js + 外部插件供应商 ----
220
+ // ---- 供应商注册表:行(内置)+ 用户目录 js + 外部插件供应商 ----
184
221
  const loadedSuppliers: LoadedSupplier[] = []
185
222
  /** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
186
223
  const supplierDisposers = new Map<string, Array<() => void>>()
@@ -224,23 +261,38 @@ export function apply(rawContext: unknown): void {
224
261
 
225
262
  // 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
226
263
  const registerLoaded = (loaded: LoadedSupplier): void => {
264
+ // 同 id 已有供应商:只有「级别更高」的那个能顶掉它(用户目录 js > 内置行 >
265
+ // 外部插件)。级别不高不低就直接丢,别把先到的那份卸了又装回来。
266
+ const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id)
267
+ if (existing !== undefined) {
268
+ if (!supplierWins(loaded.source, existing.source)) {
269
+ log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`)
270
+ return
271
+ }
272
+ log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`)
273
+ unregisterSupplier(loaded.supplier.id)
274
+ }
227
275
  loadedSuppliers.push(loaded)
228
276
  router.add(loaded.supplier)
229
277
  registerSupplierRoutes(loaded)
230
278
  }
231
279
  /** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
232
280
  let externalSupplierIds: string[] = []
233
- const loadExternal = (suppliers: Record<string, (env: SupplierEnv) => SupplierModule>): void => {
281
+ const loadExternal = (suppliers: SupplierRegistry): void => {
234
282
  for (const [sid, factory] of Object.entries(suppliers)) {
235
283
  if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
236
284
  try {
237
285
  const module = factory({ dataDir, log, store, credentials })
238
- const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`)
286
+ // 工厂上的 source 标签决定 `/health` 怎么报它:内置行标 'builtin',独立
287
+ // 安装的供应商插件不标(= external)。面板按这个值分「内置 / 插件」两组,
288
+ // 标错就把随核心分发的供应商混进插件组了。
289
+ const kind = factory.source === 'builtin' ? 'builtin' : 'external'
290
+ const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`, kind)
239
291
  registerLoaded(loaded)
240
292
  externalSupplierIds.push(loaded.supplier.id)
241
- log(`external supplier loaded: ${sid}`)
293
+ log(`${kind} supplier loaded: ${sid}`)
242
294
  } catch (err) {
243
- ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${(err as Error).message}`)
295
+ ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${(err as Error).message}`)
244
296
  }
245
297
  }
246
298
  }
@@ -268,7 +320,6 @@ export function apply(rawContext: unknown): void {
268
320
  // 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
269
321
  const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
270
322
  const { suppliers, errors } = await loadSuppliers({
271
- builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
272
323
  userDir,
273
324
  dataDir,
274
325
  store,
@@ -323,8 +374,17 @@ export function apply(rawContext: unknown): void {
323
374
  const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
324
375
  ? peek.reasoning_effort
325
376
  : 'auto'
377
+ // 宿主会话身份(adapter 从 GenerateOptions.sessionId 转来的内部头);外部客户端没有。
378
+ const sessionRaw = req.headers['x-dsh-router-session']
379
+ const session = typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined
326
380
  await router.chatCompletions(
327
- { rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '', lv },
381
+ {
382
+ rawBody: body,
383
+ stream: !!peek.stream,
384
+ model: typeof peek.model === 'string' ? peek.model : '',
385
+ lv,
386
+ ...(session === undefined ? {} : { session }),
387
+ },
328
388
  res,
329
389
  )
330
390
  }))
@@ -335,6 +395,10 @@ export function apply(rawContext: unknown): void {
335
395
  const { suppliers } = router.status()
336
396
  writeJson(res, 200, {
337
397
  ok: true,
398
+ // 宿主版本 + 徽章支持位:客户端据此决定是否挂「最近命中」控件
399
+ // (composer.dock 在 0.1.5 渲染位置不对,只有 >= 0.1.7 才挂)。
400
+ hostVersion: hostVersion ?? '',
401
+ lastHitDock: lastHitDock,
338
402
  suppliers: suppliers.map((s) => {
339
403
  const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
340
404
  return {
@@ -344,6 +408,8 @@ export function apply(rawContext: unknown): void {
344
408
  apiKeyHint: loaded?.supplier.apiKeyHint,
345
409
  capabilities: loaded ? [...loaded.capabilities] : [],
346
410
  source: loaded?.source ?? 'external',
411
+ // 开关状态:关掉的供应商**仍在列表里**(关掉后要能再开),只是不参与路由。
412
+ enabled: s.enabled,
347
413
  }
348
414
  }),
349
415
  })
@@ -374,6 +440,38 @@ export function apply(rawContext: unknown): void {
374
440
  writeJson(res, 200, { ok: true })
375
441
  })
376
442
 
443
+ route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
444
+ // 最近一次被服务的请求:给输入框旁的小指示器用。
445
+ // `?session=` 给定时只在该会话内找(当前会话视角);不给则退回全局最近。
446
+ // 附上连接显示别名与积分 —— 前端只读这一个端点,不打 status(重)。
447
+ const session = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session') ?? undefined
448
+ const last = router.usage.lastHit(session)
449
+ if (last === undefined) {
450
+ writeJson(res, 200, { ok: true, hit: null })
451
+ return
452
+ }
453
+ const { suppliers } = router.status()
454
+ const owner = suppliers.find((s) => s.id === last.supplier)
455
+ const acct = owner?.accounts.find((a) => a.uid === last.uid)
456
+ // 连接名:**优先直接查 accountNames**(面板里配的显示别名),不依赖
457
+ // status 列表里有没有这个号 —— 号未上报/未加载时 status 里就没有它,
458
+ // 只靠 acct 会回落成 uid(显示成 cb-12 而不是 k8661)。
459
+ const named = last.uid === undefined || last.uid === '' ? undefined : store.getAccountName(last.supplier, last.uid)
460
+ writeJson(res, 200, {
461
+ ok: true,
462
+ hit: {
463
+ supplier: last.supplier,
464
+ model: last.model,
465
+ requested: last.requested,
466
+ uid: last.uid,
467
+ account: named ?? acct?.nickname ?? (last.uid === undefined || last.uid === '' ? undefined : last.uid),
468
+ credits: acct?.credits,
469
+ ok: last.ok,
470
+ ts: last.ts,
471
+ },
472
+ })
473
+ })
474
+
377
475
  route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
378
476
  try {
379
477
  const models = await router.listModels()
@@ -392,26 +490,26 @@ export function apply(rawContext: unknown): void {
392
490
  })
393
491
 
394
492
  route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
395
- let body: { name?: string; strategy?: string; models?: string[] }
493
+ let body: { name?: string; models?: string[] }
396
494
  try {
397
- body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; strategy?: string; models?: string[] }
495
+ body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; models?: string[] }
398
496
  } catch {
399
497
  writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
400
498
  return
401
499
  }
402
- const result = router.createCombo(body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
500
+ const result = router.createCombo(body.name ?? '', body.models ?? [])
403
501
  writeJson(res, result.ok ? 200 : 400, result)
404
502
  })
405
503
 
406
504
  route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
407
- let body: { id?: string; name?: string; strategy?: string; models?: string[] }
505
+ let body: { id?: string; name?: string; models?: string[] }
408
506
  try {
409
- body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; strategy?: string; models?: string[] }
507
+ body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; models?: string[] }
410
508
  } catch {
411
509
  writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
412
510
  return
413
511
  }
414
- const result = router.updateCombo(body.id ?? '', body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
512
+ const result = router.updateCombo(body.id ?? '', body.name ?? '', body.models ?? [])
415
513
  writeJson(res, result.ok ? 200 : 400, result)
416
514
  })
417
515
 
@@ -528,6 +626,8 @@ export function apply(rawContext: unknown): void {
528
626
  ...(e.icon !== undefined ? { icon: e.icon } : {}),
529
627
  enabled: extStore.isEnabled(e.id),
530
628
  ready: st?.ready === true,
629
+ // 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
630
+ ...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
531
631
  ...(st?.detail !== undefined ? { detail: st.detail } : {}),
532
632
  }
533
633
  })
@@ -535,34 +635,6 @@ export function apply(rawContext: unknown): void {
535
635
  writeJson(res, 200, { ok: true, enhancers: list })
536
636
  })
537
637
 
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
-
566
638
  // ---- 概览看板:用量统计 ----
567
639
 
568
640
  /** 解析并校验周期参数(不接受任意字符串)。 */
@@ -590,7 +662,14 @@ export function apply(rawContext: unknown): void {
590
662
  bySupplier: s.bySupplier,
591
663
  byModel: s.byModel,
592
664
  byRequested: s.byRequested,
593
- recent: router.usage.recentList(20),
665
+ // 「最近请求」的「连接」列要显示连接名(uid → 显示别名)。后端一次解析,
666
+ // 前端不必再拉 /status。无 uid(失败/未服务)→ 返回空串,前端显示「—」。
667
+ recent: router.usage.recentList(20).map((r) => ({
668
+ ...r,
669
+ connection: r.uid === undefined || r.uid === ''
670
+ ? ''
671
+ : store.getAccountName(r.supplier, r.uid) ?? r.uid,
672
+ })),
594
673
  })
595
674
  })
596
675
 
@@ -610,42 +689,55 @@ export function apply(rawContext: unknown): void {
610
689
 
611
690
  // ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
612
691
 
692
+ // settings ns = 配置树行 id(cordis.patch.yml insert 的 id)—— 0.1.7 设置镜像按
693
+ // entry.options.id 收录;loader 未挂 entry 时回落到同一默认值,两版一个 ns。
694
+ const settingsNs = ctx.fiber?.entry?.options?.id ?? 'dsh-router'
695
+
613
696
  // llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
614
697
  if (ctx.llm !== undefined) {
615
- const LLM_NS = 'llm-dsh-router'
616
698
  try {
617
699
  const providerReg = ctx.llm.registerConfigurableProviders([
618
700
  {
619
701
  provider: 'router',
620
702
  displayName: 'Router',
621
- settingsNs: LLM_NS,
703
+ settingsNs,
622
704
  settingsPath: [],
623
705
  },
624
706
  ])
625
707
  disposers.push(providerReg)
626
- disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
708
+ disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
627
709
  const combos = await router.combos()
628
710
  return combos.map((c) => ({ id: c.name }))
629
711
  }))
630
712
  // Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
631
- // 「路由系统」面板;设置-模型 这边**仍必须注册 `llm-dsh-router` 命名空间**。
632
- // 原因:设置-模型 的 provider 列表只渲染 settingsNs 能在设置镜像里解析出来的
633
- // 行(见 dsh-client-ui-settings-models 的 configurable 过滤)。只调
634
- // registerConfigurableProviders 而不注册 section,Router 卡片**不会出现**。
713
+ // 「路由系统」面板;设置-模型 这边必须让 settingsNs 在设置镜像里能解析出来
714
+ // (见 dsh-client-ui-settings-models 的 configurable 过滤),否则卡片不出现。
715
+ // 按宿主版本分流(不再靠 installSection 能力检测):
716
+ // 0.1.7+:镜像行来自顶部导出的 volatile Config(ns = 行 id),无需注册;
717
+ // 0.1.5 :镜像行来自 settings.installSection。
635
718
  // 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
636
719
  const routerSettingsSchema = Schema.object({})
637
- ctx.inject(['settings'], (sctx: unknown) => {
638
- const settings = (sctx as { settings?: SettingsServiceFace }).settings
639
- if (settings === undefined) {
640
- log('settings service absent — skip Router settings-section registration')
641
- return
642
- }
643
- settings.installSection(rawContext as CordisContext, LLM_NS, routerSettingsSchema, {}, {
644
- setSource: () => {},
645
- onChange: () => {},
720
+ if (isHost017Plus(ctx.baseUrl)) {
721
+ log(`settings section driven by exported Config (${settingsNs})`)
722
+ } else {
723
+ ctx.inject(['settings'], (sctx: unknown) => {
724
+ const settings = (sctx as { settings?: SettingsServiceFace }).settings
725
+ if (settings === undefined) {
726
+ log('settings service absent — skip Router settings-section registration')
727
+ return
728
+ }
729
+ if (settings.installSection === undefined) {
730
+ // 版本说该走 installSection 但它不在(异常宿主):只记日志,不抛。
731
+ log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`)
732
+ return
733
+ }
734
+ settings.installSection(rawContext as CordisContext, settingsNs, routerSettingsSchema, {}, {
735
+ setSource: () => {},
736
+ onChange: () => {},
737
+ })
738
+ log(`Router settings section registered (${settingsNs})`)
646
739
  })
647
- log(`Router settings section registered (${LLM_NS})`)
648
- })
740
+ }
649
741
  // adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
650
742
  // 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
651
743
  // 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
@@ -655,7 +747,12 @@ export function apply(rawContext: unknown): void {
655
747
  const w = router.comboContextWindow(c)
656
748
  return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
657
749
  }),
658
- }, () => ctx.get('attachments') as RouterAttachmentStore | undefined)))
750
+ },
751
+ () => ctx.get('attachments') as RouterAttachmentStore | undefined,
752
+ undefined,
753
+ // 第五个参数 = 降级诊断出口:图片读不到会降级成占位文本,原因必须出口
754
+ // (曾经那条路径是空 catch,issue #8 的回归因此静默了两个内测版本)。
755
+ (msg) => ctx.logger.warn(`[dsh-router] ${msg}`))))
659
756
  log('llm provider (Router) + discovery + adapter registered ok')
660
757
  // 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
661
758
  // 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
@@ -708,7 +805,7 @@ export function apply(rawContext: unknown): void {
708
805
  directory,
709
806
  registeredProviders,
710
807
  settingsNamespaces,
711
- routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes('llm-dsh-router'),
808
+ routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs),
712
809
  })
713
810
  })
714
811
 
@@ -479,6 +479,80 @@ test('wire:空工具结果发空串,不伪造 (no output) 字面量', async
479
479
  assert.equal(tool?.content, '', '空结果就该是空串')
480
480
  })
481
481
 
482
+ // 0.1.7+ 消息模型:tool 结果是**独立 role:"tool" 消息**、toolCallId 挂消息级,
483
+ // ContentBlockMap 已无 'tool-result' 块。必须原样发 role:"tool",否则工具结果被
484
+ // 序列化成 role:"user"、夹在 assistant(tool_calls) 与 tool 之间,上游判定失配 → 400
485
+ // code=11148。下面几条锁死新分支;老的 'tool-result' 块路径(≤0.1.6)仍有各自的用例。
486
+ test('wire:0.1.7+ 独立 role:"tool" 消息序列化为 role:"tool",正确带 tool_call_id', async () => {
487
+ const body = await captureBody({
488
+ messages: [
489
+ { role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
490
+ { role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
491
+ ],
492
+ })
493
+ const messages = body.messages as Array<{ role: string; tool_call_id?: string; content: unknown }>
494
+ const tool = messages.find((m) => m.role === 'tool')
495
+ assert.ok(tool, '0.1.7 tool 消息必须原样发 role:"tool"')
496
+ assert.equal(tool.tool_call_id, 'c1', 'tool_call_id 取自消息级 toolCallId')
497
+ assert.equal(tool.content, 'out', '纯文本重组为字符串')
498
+ assert.equal(messages.filter((m) => m.role === 'user').length, 0, '不容许被误序列化成 user 消息')
499
+ })
500
+
501
+ test('wire:0.1.7+ 独立 role:"tool" 消息不带图时不在其后插 user(图)', async () => {
502
+ // 图片才攒批补 user(图);纯文本 tool 后面绝不能冒出 user,配对接续必须完整。
503
+ const body = await captureBody({
504
+ messages: [
505
+ { role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'a', name: 'bash', arguments: '{}' }] },
506
+ { role: 'tool', toolCallId: 'a', content: [{ type: 'text', text: 'x' }] },
507
+ { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
508
+ ],
509
+ })
510
+ const messages = body.messages as Array<{ role: string }>
511
+ // 没有任何位置允许 user 夹在 tool 与下一 assistant 之间。
512
+ for (let i = 1; i < messages.length; i++) {
513
+ const m = messages[i]
514
+ assert.ok(m, `第 ${i} 条缺失`)
515
+ assert.notEqual(m.role, 'user', `第 ${i} 条不应该是 user(tool 配对接续被插断)`)
516
+ }
517
+ })
518
+
519
+ test('wire:0.1.7+ 空 tool 结果发空串(不伪造字面量)', async () => {
520
+ const body = await captureBody({
521
+ messages: [{ role: 'tool', toolCallId: 'c2', content: [] }],
522
+ })
523
+ const tool = (body.messages as Array<{ role: string; content: string }>).find((m) => m.role === 'tool')
524
+ assert.equal(tool?.content, '', '空结果该是空串')
525
+ })
526
+
527
+ /**
528
+ * tool 结果的两种形态**都必须序列化成 role:'tool'**,且分派只看消息形状、与宿主版本无关。
529
+ *
530
+ * 背景(实测):同一宿主内两种形态都合法 —— 0.1.7 loop-built 走新形态
531
+ * (独立 role:'tool' 消息),而老会话历史经 session-format 迁移/重放时仍是旧形态
532
+ * (user 消息里的 'tool-result' 块)。所以 adapter 不能按版本号判形态对错,
533
+ * 只能按形状分派;这条锁死两种形状在**同一份 adapter**下产物一致。
534
+ */
535
+ test('wire:tool-result 新/旧两种形态都序列化为 role:"tool"(按形状分派,与版本无关)', async () => {
536
+ const newShapeMsgs = [
537
+ { role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
538
+ { role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
539
+ ]
540
+ const oldShapeMsgs = [
541
+ { role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
542
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
543
+ ]
544
+ // 新形态:原样发 role:'tool'
545
+ const n = await captureBody({ messages: newShapeMsgs })
546
+ const nt = (n.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
547
+ assert.ok(nt, '新形态应发 role:"tool"')
548
+ assert.equal(nt.tool_call_id, 'c1')
549
+ // 旧形态:由 'tool-result' 块转出 role:'tool'(同样带 tool_call_id)
550
+ const o = await captureBody({ messages: oldShapeMsgs })
551
+ const ot = (o.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
552
+ assert.ok(ot, '旧形态也应转出 role:"tool"')
553
+ assert.equal(ot.tool_call_id, 'c1')
554
+ })
555
+
482
556
  /**
483
557
  * 为什么要有这一条:只不发 `block-end` **并不能**拦住残缺调用。
484
558
  *
@@ -609,13 +683,48 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
609
683
 
610
684
  /* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
611
685
 
612
- /** 假附件 store:返回指定 mediaType + bytes 的请求版本。 */
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
+ */
613
695
  function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
614
- readImageRequest: () => Promise<{ mediaType: string; data: Uint8Array }>
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 }
615
702
  } {
616
- return {
617
- readImageRequest: async () => ({ mediaType, data }),
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
+ },
618
726
  }
727
+ return store
619
728
  }
620
729
 
621
730
  /** 用给定 attachments 跑一次 stream(),返回上游收到的 body。 */
@@ -636,6 +745,68 @@ async function captureBodyWith(options: Record<string, unknown>, attachments?: u
636
745
  return captured
637
746
  }
638
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
+
639
810
  test('wire:图片块被序列化成 OpenAI image_url base64 part(不再静默丢图)', async () => {
640
811
  const ref = {
641
812
  attachmentId: 'sha256:abcdef1234567890',