dsh-router-core 0.4.0 → 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 (96) hide show
  1. package/README.md +165 -33
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/suppliers.md +28 -5
  5. package/lib/client-registry.js +939 -132
  6. package/lib/client.js +939 -132
  7. package/lib/ext-test/index.js +47 -0
  8. package/lib/ext-test/locale/en.json +6 -0
  9. package/lib/ext-test/locale/zh.json +6 -0
  10. package/lib/ext-test/package.json +5 -0
  11. package/lib/http-state-C1vb3gW2.js +48 -0
  12. package/lib/index.js +789 -87
  13. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  14. package/lib/suppliers/nvidia/locale/en.json +6 -0
  15. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  16. package/lib/suppliers/nvidia/package.json +5 -0
  17. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  18. package/lib/suppliers/opencode/locale/en.json +6 -0
  19. package/lib/suppliers/opencode/locale/zh.json +6 -0
  20. package/lib/suppliers/opencode/package.json +5 -0
  21. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  22. package/lib/suppliers/openrouter/locale/en.json +6 -0
  23. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  24. package/lib/suppliers/openrouter/package.json +5 -0
  25. package/lib/types/ext/contract.d.ts +9 -0
  26. package/lib/types/ext-test/index.d.ts +21 -0
  27. package/lib/types/ext-test/plugin.d.ts +23 -0
  28. package/lib/types/llm/adapter.d.ts +35 -4
  29. package/lib/types/router/index.d.ts +61 -8
  30. package/lib/types/shared.d.ts +12 -0
  31. package/lib/types/supplier-config.d.ts +15 -0
  32. package/lib/types/suppliers/contract.d.ts +35 -2
  33. package/lib/types/suppliers/http-state.d.ts +23 -0
  34. package/lib/types/suppliers/loader.d.ts +6 -4
  35. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  36. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  37. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  38. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  39. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  40. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  41. package/lib/types/suppliers/probe.d.ts +160 -0
  42. package/lib/types/suppliers/registry.d.ts +3 -1
  43. package/package.json +39 -4
  44. package/src/client/ExtDetail.tsx +21 -22
  45. package/src/client/ExtTab.tsx +25 -42
  46. package/src/client/ExtTestPanel.tsx +392 -0
  47. package/src/client/RouterComponentsSection.tsx +241 -0
  48. package/src/client/RouterView.tsx +12 -5
  49. package/src/client/comp-button-scope.test.ts +58 -0
  50. package/src/client/comp-panel-layout.test.ts +58 -0
  51. package/src/client/env.d.ts +38 -1
  52. package/src/client/ext-panels.test.ts +44 -0
  53. package/src/client/ext-panels.ts +49 -0
  54. package/src/client/index.tsx +41 -0
  55. package/src/client/model-choice.test.ts +41 -0
  56. package/src/client/model-choice.ts +37 -0
  57. package/src/client/router-components.test.ts +114 -0
  58. package/src/client/router-components.ts +104 -0
  59. package/src/client/router.css +490 -9
  60. package/src/client/supplier-visibility.test.ts +57 -0
  61. package/src/client/supplier-visibility.ts +63 -0
  62. package/src/ext/contract.ts +9 -0
  63. package/src/ext-test/ext-test.test.ts +60 -0
  64. package/src/ext-test/index.ts +35 -0
  65. package/src/ext-test/locale/en.json +6 -0
  66. package/src/ext-test/locale/zh.json +6 -0
  67. package/src/ext-test/plugin.ts +34 -0
  68. package/src/ext-test/row.json +5 -0
  69. package/src/index.ts +32 -9
  70. package/src/llm/adapter.test.ts +101 -4
  71. package/src/llm/adapter.ts +77 -17
  72. package/src/router/disconnect-leak.test.ts +7 -12
  73. package/src/router/first-byte-budget.test.ts +160 -0
  74. package/src/router/index.ts +255 -35
  75. package/src/router/models-cache.test.ts +45 -0
  76. package/src/router/supplier-switch.test.ts +220 -0
  77. package/src/shared.ts +12 -0
  78. package/src/supplier-config.ts +32 -1
  79. package/src/suppliers/contract.ts +54 -2
  80. package/src/suppliers/loader.test.ts +36 -2
  81. package/src/suppliers/loader.ts +33 -18
  82. package/src/suppliers/nvidia/index.ts +27 -0
  83. package/src/suppliers/nvidia/locale/en.json +6 -0
  84. package/src/suppliers/nvidia/locale/zh.json +6 -0
  85. package/src/suppliers/nvidia/row.json +5 -0
  86. package/src/suppliers/opencode/index.ts +27 -0
  87. package/src/suppliers/opencode/locale/en.json +6 -0
  88. package/src/suppliers/opencode/locale/zh.json +6 -0
  89. package/src/suppliers/opencode/row.json +5 -0
  90. package/src/suppliers/openrouter/index.ts +27 -0
  91. package/src/suppliers/openrouter/locale/en.json +6 -0
  92. package/src/suppliers/openrouter/locale/zh.json +6 -0
  93. package/src/suppliers/openrouter/row.json +5 -0
  94. package/src/suppliers/probe.test.ts +266 -0
  95. package/src/suppliers/probe.ts +475 -0
  96. 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,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Plugin self-check",
4
+ "description": "Run a real access test against a supplier/model/connection, or run a contract check over an external supplier plugin (every member: implemented / usable / not executed)"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "插件自检",
4
+ "description": "选供应商、模型与连接跑一次真实访问测试;或对外部供应商插件做契约体检,逐个成员报告实现与可用情况"
5
+ }
6
+ }
@@ -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,5 @@
1
+ {
2
+ "name": "dsh-router-core-ext-test",
3
+ "type": "module",
4
+ "description": "插件自检扩展(随 dsh-router 核心分发)"
5
+ }
package/src/index.ts CHANGED
@@ -36,9 +36,9 @@ 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'
@@ -217,7 +217,7 @@ export function apply(rawContext: unknown): void {
217
217
  disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
218
218
  }
219
219
 
220
- // ---- 供应商注册表:内置 + 用户自定义 js + 外部插件供应商 ----
220
+ // ---- 供应商注册表:行(内置)+ 用户目录 js + 外部插件供应商 ----
221
221
  const loadedSuppliers: LoadedSupplier[] = []
222
222
  /** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
223
223
  const supplierDisposers = new Map<string, Array<() => void>>()
@@ -261,23 +261,38 @@ export function apply(rawContext: unknown): void {
261
261
 
262
262
  // 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
263
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
+ }
264
275
  loadedSuppliers.push(loaded)
265
276
  router.add(loaded.supplier)
266
277
  registerSupplierRoutes(loaded)
267
278
  }
268
279
  /** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
269
280
  let externalSupplierIds: string[] = []
270
- const loadExternal = (suppliers: Record<string, (env: SupplierEnv) => SupplierModule>): void => {
281
+ const loadExternal = (suppliers: SupplierRegistry): void => {
271
282
  for (const [sid, factory] of Object.entries(suppliers)) {
272
283
  if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
273
284
  try {
274
285
  const module = factory({ dataDir, log, store, credentials })
275
- 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)
276
291
  registerLoaded(loaded)
277
292
  externalSupplierIds.push(loaded.supplier.id)
278
- log(`external supplier loaded: ${sid}`)
293
+ log(`${kind} supplier loaded: ${sid}`)
279
294
  } catch (err) {
280
- 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}`)
281
296
  }
282
297
  }
283
298
  }
@@ -305,7 +320,6 @@ export function apply(rawContext: unknown): void {
305
320
  // 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
306
321
  const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
307
322
  const { suppliers, errors } = await loadSuppliers({
308
- builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
309
323
  userDir,
310
324
  dataDir,
311
325
  store,
@@ -394,6 +408,8 @@ export function apply(rawContext: unknown): void {
394
408
  apiKeyHint: loaded?.supplier.apiKeyHint,
395
409
  capabilities: loaded ? [...loaded.capabilities] : [],
396
410
  source: loaded?.source ?? 'external',
411
+ // 开关状态:关掉的供应商**仍在列表里**(关掉后要能再开),只是不参与路由。
412
+ enabled: s.enabled,
397
413
  }
398
414
  }),
399
415
  })
@@ -610,6 +626,8 @@ export function apply(rawContext: unknown): void {
610
626
  ...(e.icon !== undefined ? { icon: e.icon } : {}),
611
627
  enabled: extStore.isEnabled(e.id),
612
628
  ready: st?.ready === true,
629
+ // 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
630
+ ...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
613
631
  ...(st?.detail !== undefined ? { detail: st.detail } : {}),
614
632
  }
615
633
  })
@@ -729,7 +747,12 @@ export function apply(rawContext: unknown): void {
729
747
  const w = router.comboContextWindow(c)
730
748
  return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
731
749
  }),
732
- }, () => 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}`))))
733
756
  log('llm provider (Router) + discovery + adapter registered ok')
734
757
  // 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
735
758
  // 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
@@ -683,13 +683,48 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
683
683
 
684
684
  /* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
685
685
 
686
- /** 假附件 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
+ */
687
695
  function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
688
- 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 }
689
702
  } {
690
- return {
691
- 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
+ },
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',
@@ -8,7 +8,9 @@
8
8
  * `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
9
9
  * 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
10
10
  * 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
11
- * 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
11
+ * 图片字节从 `ctx.attachments` 读取。读不到时按稳定占位文本降级(不让整条消息失败),
12
+ * 但**必须留痕**:降级原因走 adapter 的 `log` 出口打到宿主 logger。曾经这里是
13
+ * 一个空 `catch {}`,字段改名导致的读图失败静默了跨两个内测版本(issue #8)。
12
14
  */
13
15
  import {
14
16
  EMPTY_RESPONSE_CODE,
@@ -31,15 +33,58 @@ import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../r
31
33
  * 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
32
34
  * devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
33
35
  * 宿主注入真实 store 时天然满足。
36
+ *
37
+ * ⚠️ **第二个参数是 `ImageRequestTarget`(`width` + `height` + `maxBytes`),
38
+ * 不是 0.1.5 时代的 `ImageRequestPolicy`(`maxPixels` + `maxBytes`)**。宿主从
39
+ * 0.1.6 起在 `validateTarget()` 里校验这三个字段,缺一个就抛
40
+ * `Image request width must be a positive integer`,读图整体失败(issue #8)。
41
+ * 这两个字段不是摆设:宿主的 `pipeline()` 按 **target 的长边**把图缩到该尺寸,
42
+ * 短边由它自己推 —— 所以这里传投影后的尺寸,就是「按预算给我这张图」。
34
43
  */
35
44
  export interface RouterAttachmentStore {
36
45
  readImageRequest(
37
46
  ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
38
- policy: { maxPixels: number; maxBytes: number },
47
+ target: { width: number; height: number; maxBytes: number },
39
48
  signal?: AbortSignal,
40
49
  ): Promise<{ mediaType: string; data: Uint8Array }>
41
50
  }
42
51
 
52
+ /** 送上游的请求图的像素预算(宽 × 高),沿用 0.1.5 时代 `maxPixels` 的意图。 */
53
+ const MAX_REQUEST_PIXELS = 64e4
54
+ /** 送上游的请求图的字节上限。 */
55
+ const MAX_REQUEST_BYTES = 1024 * 1024
56
+
57
+ /**
58
+ * 把源图尺寸投影到「总像素 ≤ maxPixels」的请求目标(等比、只缩不放)。
59
+ *
60
+ * 为什么需要它:宿主 0.1.6 起要的是 `ImageRequestTarget`(width/height/maxBytes),
61
+ * 而「别把一张 12MP 手机照原样塞进对话」这个意图原本靠 `maxPixels` 表达。
62
+ * 宿主自己**只按 target 的长边**缩放、短边由它推,所以我们要做的只是给出一组
63
+ * 落在预算内的正整数;短边取 floor 即可(它只参与校验与缓存键,不决定出图尺寸)。
64
+ *
65
+ * 没有直接复用官方的 `requestImageDimensions`(`@deepseek-ai/dsh-attachment` 有导出):
66
+ * 本文件刻意不依赖那个包(见 `RouterAttachmentStore` 的说明 —— 它是宿主注入的
67
+ * devDependency,引入它等于给插件加一条编译期/运行期依赖)。这 8 行是纯函数、
68
+ * 有测试钉住;真要换成官方实现时,语义等价。
69
+ */
70
+ export function projectImageDimensions(
71
+ width: number,
72
+ height: number,
73
+ maxPixels: number,
74
+ ): { width: number; height: number } {
75
+ const pixels = width * height
76
+ // 尺寸缺失/非法时不投影:让宿主自己的校验去报错,别在这里造一个假尺寸掩盖问题
77
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
78
+ return { width, height }
79
+ }
80
+ if (pixels <= maxPixels) return { width, height }
81
+ const scale = Math.sqrt(maxPixels / pixels)
82
+ return {
83
+ width: Math.max(1, Math.floor(width * scale)),
84
+ height: Math.max(1, Math.floor(height * scale)),
85
+ }
86
+ }
87
+
43
88
  /** 一张 image 块的 attachment ref 的最小形状(value 类型,够用即可)。 */
44
89
  interface RouterImgLike {
45
90
  attachmentId: string
@@ -96,7 +141,7 @@ const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
96
141
  */
97
142
  const STREAM_IDLE_TIMEOUT_MS = 300_000
98
143
 
99
- async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
144
+ async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined, log?: (msg: string) => void): Promise<Array<Record<string, unknown>>> {
100
145
  const out: Array<Record<string, unknown>> = []
101
146
  /**
102
147
  * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
@@ -157,7 +202,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
157
202
  // 图片不能放进 tool 消息(实测模型会把两张不同图认成同一张),也不能插在两条 tool
158
203
  // 之间(同样 400),所以先攒进 pending,等整串 tool 发完再补一条 user(图)。
159
204
  const toolCallId = (message as { toolCallId?: unknown }).toolCallId
160
- const parts = await contentParts(message.content, attachments, options.signal)
205
+ const parts = await contentParts(message.content, attachments, options.signal, log)
161
206
  const imageParts = parts.filter((p) => p.type !== 'text')
162
207
  const text = parts
163
208
  .filter((p) => p.type === 'text')
@@ -176,6 +221,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
176
221
  message.content.filter((b) => b.type !== 'tool-result'),
177
222
  attachments,
178
223
  options.signal,
224
+ log,
179
225
  )
180
226
  // 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
181
227
  // 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
@@ -189,7 +235,7 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
189
235
  // 11148「tool calls and tool results do not match」。
190
236
  // 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
191
237
  // 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
192
- const parts = await contentParts(result.content, attachments, options.signal)
238
+ const parts = await contentParts(result.content, attachments, options.signal, log)
193
239
  const imageParts = parts.filter((p) => p.type !== 'text')
194
240
  const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
195
241
  // 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
@@ -224,6 +270,7 @@ async function contentParts(
224
270
  blocks: ReadonlyArray<{ type: string }>,
225
271
  attachments: RouterAttachmentStore | undefined,
226
272
  signal: AbortSignal | undefined,
273
+ log?: (msg: string) => void,
227
274
  ): Promise<Array<Record<string, unknown>>> {
228
275
  const parts: Array<Record<string, unknown>> = []
229
276
  for (const block of blocks) {
@@ -234,11 +281,11 @@ async function contentParts(
234
281
  }
235
282
  if (block.type === 'image') {
236
283
  const ref = (block as unknown as { attachment: RouterImgLike }).attachment
237
- parts.push(...(await imageParts(ref, attachments, signal)))
284
+ parts.push(...(await imageParts(ref, attachments, signal, log)))
238
285
  continue
239
286
  }
240
287
  if (block.type === 'tool-result') {
241
- parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal)))
288
+ parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal, log)))
242
289
  }
243
290
  // 其它块(reasoning/tool-call)不属于 user content,忽略
244
291
  }
@@ -250,16 +297,23 @@ async function imageParts(
250
297
  ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
251
298
  attachments: RouterAttachmentStore | undefined,
252
299
  signal: AbortSignal | undefined,
300
+ log?: (msg: string) => void,
253
301
  ): Promise<Array<Record<string, unknown>>> {
254
- if (attachments === undefined) return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
302
+ if (attachments === undefined) {
303
+ log?.('图片附件 store 不可用(ctx.attachments 未注入),该图降级为占位文本')
304
+ return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
305
+ }
306
+ const ref0 = { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }
307
+ // 目标 = 源尺寸按 64e4 像素预算等比投影后的宽高 + 字节上限(0.1.6+ 的 target 形状)
308
+ const target = { ...projectImageDimensions(ref.width, ref.height, MAX_REQUEST_PIXELS), maxBytes: MAX_REQUEST_BYTES }
255
309
  try {
256
- const img = await attachments.readImageRequest(
257
- { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
258
- { maxPixels: 64e4, maxBytes: 1024 * 1024 },
259
- signal,
260
- )
310
+ const img = await attachments.readImageRequest(ref0, target, signal)
261
311
  return [{ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` } }]
262
- } catch {
312
+ } catch (err) {
313
+ // 曾经这里是空 catch:宿主把请求目标从 maxPixels 换成 width/height 之后读图
314
+ // 全失败,图被静默换成占位文本,跨两个内测版本没被发现(issue #8)。降级仍然
315
+ // 保留(不能让整条消息失败),但原因必须出口。
316
+ log?.(`读取图片 ${ref.attachmentId} 失败,已降级为占位文本:${(err as Error).message}`)
263
317
  return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
264
318
  }
265
319
  }
@@ -280,8 +334,8 @@ function imagePlaceholder(attachmentId: string): string {
280
334
  }
281
335
 
282
336
  /** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
283
- async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined): Promise<Record<string, unknown>> {
284
- const messages = await wireMessages(options, options.system, attachments)
337
+ async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined, log?: (msg: string) => void): Promise<Record<string, unknown>> {
338
+ const messages = await wireMessages(options, options.system, attachments, log)
285
339
  const body: Record<string, unknown> = {
286
340
  model: options.model,
287
341
  messages,
@@ -585,22 +639,28 @@ export class RouterAdapter extends LlmAdapter {
585
639
  private readonly resolveAttachments: () => RouterAttachmentStore | undefined
586
640
  /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
587
641
  private readonly idleTimeoutMs: number
642
+ /** 降级/失败诊断的出口(宿主接 ctx.logger.warn)。缺省丢弃 —— 仅为测试可注入。 */
643
+ private readonly log: ((msg: string) => void) | undefined
588
644
 
589
645
  /**
590
646
  * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
591
647
  * 可注入只为测试能压到几百毫秒,生产不传。
648
+ * @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
649
+ * 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
592
650
  */
593
651
  constructor(
594
652
  baseURL: string,
595
653
  source: RouterAdapterSource,
596
654
  resolveAttachments?: () => RouterAttachmentStore | undefined,
597
655
  idleTimeoutMs: number = STREAM_IDLE_TIMEOUT_MS,
656
+ log?: (msg: string) => void,
598
657
  ) {
599
658
  super()
600
659
  this.baseURL = baseURL
601
660
  this.source = source
602
661
  this.resolveAttachments = resolveAttachments ?? (() => undefined)
603
662
  this.idleTimeoutMs = idleTimeoutMs
663
+ this.log = log
604
664
  }
605
665
 
606
666
  providerInfo(provider: string): { id: string; name: string } {
@@ -647,7 +707,7 @@ export class RouterAdapter extends LlmAdapter {
647
707
  }
648
708
 
649
709
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
650
- const body = await wireRequest(options, this.resolveAttachments())
710
+ const body = await wireRequest(options, this.resolveAttachments(), this.log)
651
711
  const controller = new AbortController()
652
712
  const onAbort = (): void => controller.abort()
653
713
  options.signal?.addEventListener('abort', onAbort, { once: true })
@@ -158,28 +158,24 @@ test('客户端断开:上游流 cancel 链要穿过 tapStreamUsage / normalize
158
158
  assert.equal(leak.cancelled(), true, '取消必须穿过两层 wrapper 到达上游')
159
159
  })
160
160
 
161
- test('聚合失败:reader 必须被解绑(否则连接还活着时上层无法 cancel)', async () => {
161
+ test('聚合失败:上游流必须被回收(解锁,不能永久锁死)', async () => {
162
162
  // 这一条测的**不是**「cancel 回调有没有触发」——那测不出东西:
163
163
  // 实测结论(两个都验过):
164
164
  // - 流 error 后调 cancel(),只会以同一错误 reject,**不触发** cancel 回调
165
165
  // (`pull()` 抛错会把流置为 errored;上游拆连接则流直接 error)
166
166
  // - 这两种情况下 undici **已经自己关了连接**,不需要我们 cancel
167
- // 所以聚合失败路径真正要保证的是 **reader 被解绑**:流锁着的话,上层
167
+ // 所以聚合失败路径真正要保证的是 **上游流不再被锁着**:锁着的话上层
168
168
  // (writeChatResult 的失败路径 / 未来的取消逻辑)想 cancel 都会抛
169
169
  // `Invalid state: ReadableStream is locked`,连接就永久漏了。
170
- let releaseLocked = false
170
+ //
171
+ // 注意拓扑:核心现在隔着「首字节截止」wrapper(withFirstByteDeadline)读上游,
172
+ // 所以判据只能是**最终状态**(locked=false),不能再断言核心直接 releaseLock
173
+ // 上游的 reader —— 中间隔了一层,核心只会解锁 wrapper 那个 reader。
171
174
  const enc = new TextEncoder()
172
175
  const failing = new ReadableStream<Uint8Array>({
173
176
  start(c) { c.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"a"}}]}\n\n')) },
174
177
  pull() { throw new Error('aggregate read failed') },
175
178
  })
176
- const origGetReader = failing.getReader.bind(failing)
177
- ;(failing as unknown as { getReader: () => unknown }).getReader = () => {
178
- const r = origGetReader()
179
- const origRelease = r.releaseLock.bind(r)
180
- r.releaseLock = () => { releaseLocked = true; origRelease() }
181
- return r
182
- }
183
179
 
184
180
  const router = new Router('')
185
181
  router.add(supplierWith(failing) as never)
@@ -195,8 +191,7 @@ test('聚合失败:reader 必须被解绑(否则连接还活着时上层无
195
191
  )
196
192
  await new Promise((r) => setTimeout(r, 150))
197
193
 
198
- assert.equal(releaseLocked, true, '聚合失败后必须 releaseLock,否则流锁死、上层无法回收')
199
- assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
194
+ assert.equal(failing.locked, false, '上游流必须处于未锁定状态(可被 cancel / 已被回收)')
200
195
  })
201
196
 
202
197
  test('fixFrame:把后续 delta 里重发成 null 的 id/name 剥掉(否则外部 OpenAI 客户端会丢工具名)', async () => {