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
@@ -255,3 +255,161 @@ pick(round-robin):
255
255
  ponytail:会话标识目前靠前缀指纹推断,真要百分百准得等 DSH 在 `ChatRequest`
256
256
  透传会话 id(需改契约,不做);指纹对「首条消息就分叉」的客户端(极少见)
257
257
  会散得比会话更细,退化为均衡分发,无害。
258
+
259
+ ## 10. 统一模型:会话亲和选号(2026-09-23 落地,废除策略旋钮)
260
+
261
+ ### 10.0 结论先行:没有「策略」
262
+
263
+ `fallback`(永远取健康池第一个)与 `round-robin`(轮转)**都是没有会话身份
264
+ 时代的产物**。有了会话亲和后,二者都错:
265
+
266
+ - `fallback` 让**所有**会话挤同一个号 → team/多会话下必互踩(§2.1 实测 3.8%)。
267
+ - `round-robin` 的"轮转"语义与亲和"同会话永不换号"直接冲突。
268
+
269
+ **正确模型只有一条,无需旋钮:**
270
+
271
+ ```
272
+ 选号 = f(会话身份, 该 model 下的健康号集合)
273
+ 1. 会话身份 → 亲和键;该键绑定的号仍健康 → 用它
274
+ 2. 无绑定(新会话)→ 从健康池挑一个(避开降权号),记下绑定
275
+ 3. 无身份(指纹算不出)→ 块轮询兜底(§2),这不是"策略",是无身份时的替身
276
+ 4. 该 model 下全不健康 → 交给组合的降级链,换下一个 model(§10.2)
277
+
278
+ 号数 ≥ 并发会话数 → 每个会话独占一个号,缓存空间互不驱逐
279
+ 号数 < 并发会话数 → 必然有会话共用号 → 共用者互踩,不可消除(见 10.3 天花板)
280
+ ```
281
+
282
+ ### 10.1 关键认知边界:会话 id = 选号稳定性,不是缓存隔离
283
+
284
+ **必须分清**,否则会做出错误期望:
285
+
286
+ | 能力 | 会话 id 能带来吗 |
287
+ |---|---|
288
+ | 选号时认出"这是哪个会话",避免误绑 | ✅ 能 |
289
+ | 保证同一会话永远绑同一个号(不漂移) | ✅ 能 |
290
+ | **同一个号上,不同会话的缓存各占一块、互不驱逐** | ❌ **不能** |
291
+
292
+ 上游 prompt cache 是 `per-account × per-conversation` 命名空间,**同一个号
293
+ 上的缓存空间是共享的、会被互相驱逐的**。session id 只是"货物标签",帮上游
294
+ 知道哪批是哪批;**仓库满了照样扔货**,不会因为标签不同就给每批货独立仓位。
295
+
296
+ 证据:§2.1 实测那 3 个并发会话本就走不同的对话内容(等价于不同 id 语义),
297
+ 照样只命中 3.8% —— **驱逐是账号侧物理限制,不归 id 管**。
298
+
299
+ > 所以「号不够就共用号 + 靠会话 id 分开缓存」**前半成立(选得准)、
300
+ > 后半不成立(缓存仍互踩)**。
301
+
302
+ ### 10.2 组合 = 降级链(不是轮转)
303
+
304
+ 组合(一组模型)的语义统一为:**从第一个模型起顺序尝试,谁先服务成功就
305
+ 停**。原 `round-robin` 组合(换模型轮转)**删除** —— 因为换模型 = 换供应商
306
+ = 整段前缀缓存作废,与亲和的价值直接冲突。
307
+
308
+ ```
309
+ 组合选模型: start = 0 恒定(删掉 rrCursors 游标与 strategy 字段)
310
+ 对 combo.models[i] 依次:
311
+ chatWithModel(该模型的降级链) → 成功即停
312
+ 瞬时故障 → 等 TRANSIENT_SETTLE_MS 再试下一个(§已有)
313
+ 全失败 → 503 combo: all models unavailable
314
+ ```
315
+
316
+ 选号(§10.0)与选模型(本节)是**正交的两条轴**:前者在"单个 model 的账号池"
317
+ 内做亲和,后者在"多个 model"间做降级。二者的共同点:**都只按"身份 + 健康性"
318
+ 决策,不按"轮转"**。
319
+
320
+ ### 10.3 天花板(ponytail)
321
+
322
+ 1. **号数 < 并发会话数时必然互踩** —— 物理限制(缓存空间有限),只能
323
+ **加号**或**限制 team 并发成员数 ≤ 号数**,任何选号手法都消除不了。
324
+ 2. ~~**会话身份目前靠前缀指纹推断**~~ —— **2026-09-23 已落地真会话 id**(见 §11):
325
+ 宿主 `GenerateOptions.sessionId` → adapter 转成 `x-dsh-router-session` 头 →
326
+ 核心按会话亲和。指纹降为**无会话身份时的兜底**(外部 OpenAI 客户端)。
327
+ 3. **上游 TTL 仍未知**(§8):长会话若间隔超过 TTL,miss 是上游行为,
328
+ 不误判成选号问题。
329
+
330
+ ### 10.4 废除的旧配置
331
+
332
+ | 旧字段 | 处置 |
333
+ |---|---|
334
+ | `supplier-config.json` 的 `poolStrategy` | **静默忽略**:读盘不解析,写盘不产出;旧文件不报错,下次保存自动瘦身 |
335
+ | `combos.json` 的 `strategy` | 同上 |
336
+ | `GET/POST /suppliers/:id/pool/strategy` 路由 | 删除 |
337
+ | 面板「连接池策略」「组合策略」下拉 | 删除(选项无意义则不该占位) |
338
+
339
+ ### 10.5 为什么这不是"回退/轮询"的合并,而是概念删除
340
+
341
+ 旧的两策略试图用**一个旋钮**回答两个不同的问题("要不要均衡" vs "要不要
342
+ 粘缓存"),而这两个问题在亲和下**同时被回答了**:亲和天然均衡(新会话按
343
+ 游标分发)+ 天然粘缓存(同会话固定号)。旋钮消失不是"合并了两个选项",
344
+ 是**承认这个问题本身提错了** —— 不存在"要不要均衡"的选择,只有"按身份
345
+ 选号"这一个正确答案。
346
+
347
+ ## 11. Agent Team 支持(2026-09-23 落地)
348
+
349
+ DSH 0.1.7 的**智能体团队**(agent team)把一个任务拆给多个成员,**每个成员
350
+ 是一个独立会话**(TeamId = Lead 的 SessionId;成员各自独立 `sessionId`)。
351
+ 这正好把 §10 的"多并发会话"从理论变成日常 —— 一轮 team 测试就是 3 个会话
352
+ 交错发请求。本节记录为此做的三件事。
353
+
354
+ ### 11.1 真会话 id 通道(替代前缀指纹)
355
+
356
+ 宿主 `dsh-llm` 的 `GenerateOptions.sessionId` 是**权威会话身份**。链路:
357
+
358
+ ```
359
+ dsh-llm GenerateOptions.sessionId
360
+ → RouterAdapter 发请求时带 `x-dsh-router-session: <sessionId>`
361
+ → 核心 /v1 读进 ChatRequest.session
362
+ → 选号: sessionFingerprint(session, messages) = session!=空 ? `sid:<session>` : 前缀指纹
363
+ → 落账: UsageRecord.session
364
+ → 面板: /router/api/last-hit?session=<id> 按当前会话取「最近命中」
365
+ ```
366
+
367
+ 要点:
368
+ - **有会话 id 时以它为主键**("sid:xxx"),精确、零撞车;指纹只在**拿不到
369
+ 会话 id 时**(外部 OpenAI 客户端)兜底。`sessionFingerprint` 就这一行分叉。
370
+ - 会话 id 是**选号稳定性**凭证,**不是缓存隔离**承诺 —— 见 §10.1,别混淆。
371
+
372
+ ### 11.2 fresh 会话优先「铺开」到空闲号
373
+
374
+ 三个 fresh 成员交替到达时,若只按全局游标分发,会**回绕撞号**(实测:
375
+ Lead 与 T1 撞同一个号,T2 独占)。改为**占用感知分配**(`allocSession`):
376
+
377
+ ```
378
+ 新会话分配:
379
+ 1. 先算「本 model 下没有被其它会话绑定的空闲号」(freeUids)
380
+ 2. 空闲号非空 → 从空闲号里按游标挑 → 各会话尽量独占一个号
381
+ 3. 空闲号用尽 → 才回绕到已占用的号复用(复用无害,见 §11.3)
382
+ ```
383
+
384
+ "占用"= 该 model 的亲和绑定表里,**其它**指纹当前指向的 uid;同一会话自己
385
+ 的旧绑定不算占用(它改绑不冲突)。号数 ≥ 会话数 → 每个会话独占;号数 <
386
+ 会话数 → 必然复用(物理限制,§10.3)。
387
+
388
+ ### 11.3 缓存质量样本按会话隔离(消除「降权串」)
389
+
390
+ **症状**(实测):team 3 个会话全落到同一个号,且该号看起来"缓存坏了"。
391
+
392
+ **根因**:`samples`(缓存质量样本)与 `demoted`(降权)的键原为
393
+ `(supplier, model, uid)`,**没有会话维度**。多会话共用同号时,各自前缀在
394
+ 该号缓存里互相驱逐 → 命中率被**混在一起算** → 跌破阈值 → 误判"这个号缓存
395
+ 坏了" → 降权 → 所有会话被挤到剩余号 → 那个号又被同样机制打低 → **降权串**。
396
+
397
+ **修法**:键改为 `(supplier, model, uid[, session])`(`sampleKey`)。有会话
398
+ 身份时**按会话隔离统计**,降权只反映「这个会话在这个号上真的反复不命中」,
399
+ 不因别的会话共用同号而被牵连。无会话身份(外部客户端走无指纹路径)保持
400
+ `(model, uid)` 原语义。
401
+
402
+ 回归测试:`account-pool.test.ts` 的「多会话共用同号互踩 → 不误伤降权」——
403
+ 两会话各在该号服务 24 次、命中率 50%,旧逻辑立刻误降权,新逻辑不降;
404
+ 同时锁死"单会话自己真攒满 48 次全 miss 仍会被降权"(隔离 ≠ 免疫)。
405
+
406
+ ### 11.4 天花板(ponytail)
407
+
408
+ - **隔离让降权更迟钝**:会话越分散,单个会话攒满样本越慢 → 降权触发越晚。
409
+ 这是**想要的**(宁可慢也不要误伤)。要更快感知真坏号,调小 `BLOCK_MAX`
410
+ (当前 48)。
411
+ - **号数 < 成员数仍互踩**:隔离只是**不误判**,不改变"缓存空间共享"这个
412
+ 物理事实(§10.1/§10.3)。真要不互踩只能加号或减少并发成员。
413
+ - **每会话一份样本 → 内存随会话数增长**:`samples` 按会话分叉,长跑多会话
414
+ 场景条目变多。当前无上限(随进程存亡),量大时再加 LRU(对齐 `AFFINITY_MAX`
415
+ 的做法)。
package/docs/suppliers.md CHANGED
@@ -152,6 +152,21 @@ type ChatOnceResult =
152
152
  重启后所有号都会重新被选中一次,所以**上游确实挂了的号会各撞一次**,属预期。
153
153
  要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
154
154
 
155
+ ### 首字节预算:body 停摆由核心兜,js 不要自己设
156
+
157
+ 上游常有一种「连上了、200 响应头也回来了、body 却一个字节都不吐」的停摆
158
+ (额度用完 / 过载时的静默排队)。`chatOnce` 已经把流交出去之后,js 就没有任何
159
+ 「换号/换模型」的能力了,所以这个截止只能由**核心**设:
160
+
161
+ - 每条降级腿进核心起 60s(`FIRST_BYTE_BUDGET_MS`),上游还没吐第一个字节就判
162
+ 这条腿失败 → 按 `transport` 走换号/换模型(2026-09-24 现场:ttfb 84~255s
163
+ 的 8 次请求全挂在一条腿上,用户干等 200 多秒手动暂停)
164
+ - **第一个字节之后不再计时**,长生成不受影响
165
+ - 同一供应商的多个号共享这份预算(停滞是上游级的,按号各等一份会 60s×号数)
166
+
167
+ 因此 js 侧的 `chatOnce` 超时**只守「连接 + 响应头」**就够了,不必、也不该给
168
+ body 再套一层空闲超时:响应头一到就把控制权交回核心,body 的事归核心。
169
+
155
170
  ### 流式一旦写出第一个字节才绑死
156
171
 
157
172
  核心**推迟到第一个字节才写响应头**,所以:
@@ -313,11 +328,19 @@ export function dispose() {}
313
328
 
314
329
  ## 加载顺序
315
330
 
316
- 1. 内置:`<plugin>/lib/suppliers/*.js`(随插件分发)
317
- 2. 用户:`~/.dsh/profiles/web/suppliers/*.js`(**覆盖**内置同 id)
318
- 3. 外部插件:其他 DSH 插件通过 cordis service `router.suppliers`
319
- (值为 `{ [supplierId]: (env) => SupplierModule }`)暴露供应商,dsh-router
320
- `ctx.inject(['router.suppliers'])` 延迟加载(service 可用即注册)。
331
+ 两个来源,同 id 时**用户目录的 js 压过内置行**(`supplierWins`:user > builtin > external):
332
+
333
+ 1. **cordis service `router.suppliers`**(值为 `{ [supplierId]: (env) => SupplierModule }`),
334
+ dsh-router `ctx.inject(['router.suppliers'])` 延迟加载(service 可用即注册)。两条子路径都走它:
335
+ - **内置三个 = dsh-router-core 自己 patch 里的行**:`cordis.patch.yml` insert
336
+ `dsh-router-core/suppliers/<x>` 三个子路径模块,每行一个 cordis 壳(`src/suppliers/<x>/index.ts`)
337
+ 负责把工厂登记进表。于是它们在官方插件页 dsh-router-core 详情页的「包含的组件」里
338
+ **各占一行、带着宿主管的开关**。行的显示名/说明走**子路径自己的** `row.json` +
339
+ `locale/*.json`(构建期拷成 `lib/suppliers/<x>/{package.json,locale/}`),所以三行各有各的名字。
340
+ 内置必须是**行**而不是「扫 `<plugin>/lib/suppliers/` 目录」:扫目录拿不到行的
341
+ 身份(显示名/说明/图标),也拿不到宿主的开关 —— 单独关一个内置供应商就没有入口。
342
+ - 外部 DSH 插件(独立安装的供应商包)经同一个 service 注册。
343
+ 2. **用户目录** `~/.dsh/profiles/<profile>/suppliers/*.js`(只认 `*.js`;同 id 多个文件按文件名序后者覆盖前者)。
321
344
  cordis 的 `provide` 每个 service name 只允许一个插件提供,多个供应商插件采用
322
345
  **共享表模式**:先 provide 的插件持有聚合表对象,
323
346
  其余插件 **`inject` 后把工厂追加进同一对象**并广播一次