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/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { basename, dirname, join, resolve } from "node:path";
2
3
  import Schema from "@deepseek-ai/schemastery";
3
4
  import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
@@ -68,7 +69,27 @@ function textOf(content) {
68
69
  return s;
69
70
  }
70
71
  /**
71
- * 算请求的前缀指纹。
72
+ * 算请求的亲和指纹(选号主键)。
73
+ *
74
+ * **身份优先,内容兜底**:
75
+ * 1. 宿主给了会话身份(`session`,来自 dsh-llm 的 `sessionId`)→ 直接用它。
76
+ * 精确、零撞车 —— 同一会话永不漂移,不同会话(含 team 各成员)天然隔离。
77
+ * 2. 没有身份(外部 OpenAI 客户端)→ 回落 {@link prefixFingerprint}。
78
+ *
79
+ * 为什么优先身份而不是内容指纹:DSH 的 system prompt 是同一个 agent preset
80
+ * 生成的几 KB 文本,内容指纹只取前 512 字符 → 各会话高度雷同 → 撞车导致
81
+ * 绑定互相覆盖、同会话漂移(team 多成员时实测过)。
82
+ *
83
+ * @param session 宿主会话身份;无则 undefined
84
+ * @param messages OpenAI 请求体的 `messages`(无身份时才用)
85
+ * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
86
+ */
87
+ function sessionFingerprint(session, messages) {
88
+ if (session !== void 0 && session !== "") return `sid:${session}`;
89
+ return prefixFingerprint(messages);
90
+ }
91
+ /**
92
+ * 算请求的前缀指纹(内容兜底)。
72
93
  * @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
73
94
  * @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
74
95
  */
@@ -184,9 +205,9 @@ var AccountPool = class {
184
205
  rrCursor = 0;
185
206
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
186
207
  blocks = /* @__PURE__ */ new Map();
187
- /** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
208
+ /** (supplier, model, uid[, session]) → 该号在某会话下的缓存质量样本。 */
188
209
  samples = /* @__PURE__ */ new Map();
189
- /** (supplier, model, uid) → 缓存无效降权。 */
210
+ /** 同上键 → 缓存无效降权。 */
190
211
  demoted = /* @__PURE__ */ new Map();
191
212
  /** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
192
213
  affinity = /* @__PURE__ */ new Map();
@@ -196,6 +217,22 @@ var AccountPool = class {
196
217
  key(model, uid) {
197
218
  return `${this.supplierId}${SEP}${model}${SEP}${uid}`;
198
219
  }
220
+ /**
221
+ * 缓存质量样本/降权的键。
222
+ *
223
+ * **按会话隔离**(team 场景的关键):同一个号被多个会话共用时,各会话的
224
+ * 前缀会互相驱逐,把它们混在一起算命中率,会把「多会话互踩」误判成
225
+ * 「这个号缓存坏了」→ 错误降权 → 只剩一个号 → 那个号又被打低 → 降权串。
226
+ * 所以有会话身份时按 (model, uid, session) 记,降权只反映**该会话自己**
227
+ * 在这个号上的真实缓存表现。
228
+ *
229
+ * 无会话身份(外部 OpenAI 客户端,走无指纹回退路径)→ 保持 (model, uid),
230
+ * 与块轮询的原语义一致(那时本就是单会话视角)。
231
+ */
232
+ sampleKey(model, uid, session) {
233
+ const base = this.key(model, uid);
234
+ return session === void 0 || session === "" ? base : `${base}${SEP}${session}`;
235
+ }
199
236
  entry(model, uid) {
200
237
  const k = this.key(model, uid);
201
238
  let e = this.cooldowns.get(k);
@@ -238,48 +275,98 @@ var AccountPool = class {
238
275
  return c.until <= now;
239
276
  }
240
277
  /**
241
- * 按策略为「某个模型」选一个健康账号。
278
+ * 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
242
279
  *
243
- * round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
280
+ * 旧的两策略(fallback 取第一个 / round-robin 轮转)都删了:前者让所有
281
+ * 会话挤同一个号(team/多会话必互踩),后者的"轮转"与亲和"同会话永不换号"
282
+ * 直接冲突。正确模型只有一条:
244
283
  *
245
284
  * 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
246
- * 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
247
- * 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
248
- * 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
285
+ * 同一个会话的连续请求**永远落回同一个号**,前缀在该号缓存里只写一份。
249
286
  * 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
250
287
  * 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
251
- * 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
252
- * 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
288
+ * 3. 指纹算不出(拿不到 messages)→ **块轮询兜底**(§2 旧行为)。这不是
289
+ * "策略",是无会话身份时的替身。
290
+ * 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
291
+ * 降权该号(缓存无效,可恢复)。
253
292
  *
254
- * 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
255
- * 设计见 docs/pool-sticky-block.md §8。
293
+ * 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
294
+ * (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
256
295
  *
257
296
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
258
297
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
259
- * @param strategy fallback 取第一个健康 / round-robin 前缀亲和
260
298
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
261
- * @param messages 请求体的 messages(可选;给了才能算前缀指纹)
299
+ * @param messages 请求体的 messages(算亲和指纹的内容兜底)
300
+ * @param session 宿主会话身份;给了就以它为主键(精确、零撞车)
262
301
  * @returns 选中的 uid;无健康账号返回 undefined
263
302
  */
264
- pick(accounts, poolOrder, strategy, modelId, messages) {
303
+ pick(accounts, poolOrder, modelId, messages, session) {
265
304
  const now = Date.now();
266
305
  const byUid = new Map(accounts.map((a) => [a.uid, a]));
267
306
  const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
268
- if (healthy.length === 0) return void 0;
269
- if (strategy !== "round-robin") return healthy[0];
270
- const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now));
307
+ if (healthy.length === 0) {
308
+ this.lastWhy = "全池无健康号(都在冷却)";
309
+ return;
310
+ }
311
+ const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now, session));
271
312
  const pool = usable.length > 0 ? usable : healthy;
272
- const fp = messages === void 0 ? "" : prefixFingerprint(messages);
273
- if (fp !== "" && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? "")) return this.affinity.get(this.blockKey(modelId)).get(fp);
313
+ const fp = sessionFingerprint(session, messages);
314
+ const bound = fp === "" ? void 0 : this.affinity.get(this.blockKey(modelId))?.get(fp);
315
+ if (bound !== void 0 && pool.includes(bound)) {
316
+ this.lastWhy = `亲和命中 ${bound}(会话/前缀已绑定)`;
317
+ return bound;
318
+ }
319
+ const why = [];
320
+ if (fp === "") why.push("无指纹(拿不到 messages/会话)");
321
+ else if (bound === void 0) why.push("该指纹无绑定(会话首次出现)");
322
+ else if (!healthy.includes(bound)) {
323
+ const c = this.cooldowns.get(this.key(modelId, bound));
324
+ why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
325
+ } else if (!usable.includes(bound)) why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`);
326
+ else why.push(`原绑定 ${bound} 不在当前健康池`);
274
327
  const block = this.blocks.get(this.blockKey(modelId));
275
- if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) return block;
276
- const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
328
+ if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) {
329
+ this.lastWhy = `块驻留 ${block}(无指纹路径)`;
330
+ return block;
331
+ }
332
+ const uid = fp !== "" ? this.allocSession(pool, modelId, fp) : pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
277
333
  this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
278
334
  this.blocks.set(this.blockKey(modelId), uid);
279
- if (fp !== "") this.bind(modelId, fp, uid);
280
- else this.samples.delete(this.key(modelId, uid));
335
+ if (fp !== "") {
336
+ this.bind(modelId, fp, uid);
337
+ const free = this.freeUids(pool, modelId, fp);
338
+ why.push(free.length === 0 ? `号已铺满,按游标复用 ${uid}` : `铺开到空闲号 ${uid}`);
339
+ } else {
340
+ this.samples.delete(this.key(modelId, uid));
341
+ why.push(`块推进到 ${uid}`);
342
+ }
343
+ this.lastWhy = why.join(";");
281
344
  return uid;
282
345
  }
346
+ /**
347
+ * 新会话分配:优先返回**未被其它会话占用**的号(按游标推进),
348
+ * 空闲号用尽才回绕到已占用的号(此时按游标复用,语义等同旧行为)。
349
+ *
350
+ * 「占用」= 该 model 的绑定表里,某个 **其它** 指纹当前指向该 uid。
351
+ * 同一会话自己的旧绑定不算占用(它已经在上面的亲和分支处理过,走到这里
352
+ * 说明原绑定不可用,允许它改绑到自己的其它号或空闲号)。
353
+ */
354
+ allocSession(pool, modelId, fp) {
355
+ const free = this.freeUids(pool, modelId, fp);
356
+ const candidates = free.length > 0 ? free : pool;
357
+ return candidates[this.rrCursor % candidates.length];
358
+ }
359
+ /** 当前 model 下、本指纹之外没有任何其它会话绑定的号(按 pool 原顺序)。 */
360
+ freeUids(pool, modelId, fp) {
361
+ const taken = /* @__PURE__ */ new Set();
362
+ const m = this.affinity.get(this.blockKey(modelId));
363
+ if (m !== void 0) {
364
+ for (const [k, v] of m) if (k !== fp) taken.add(v);
365
+ }
366
+ return pool.filter((uid) => !taken.has(uid));
367
+ }
368
+ /** 上一次 pick 的选号理由(诊断用:解释"为什么没粘住原号")。 */
369
+ lastWhy = "";
283
370
  /** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
284
371
  bind(modelId, fp, uid) {
285
372
  const k = this.blockKey(modelId);
@@ -295,9 +382,14 @@ var AccountPool = class {
295
382
  if (oldest !== void 0) m.delete(oldest);
296
383
  }
297
384
  }
298
- /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
299
- isDemoted(uid, modelId, now) {
300
- const e = this.demoted.get(this.key(modelId, uid));
385
+ /**
386
+ * 该号在**本会话**(有身份时)本模型上是否被判定「缓存无效」(降权中)。
387
+ *
388
+ * 判据按会话隔离(见 sampleKey):降权只惩罚「这个会话在这个号上真的
389
+ * 反复不命中」,不会因别的会话共用同号互踩而被牵连。
390
+ */
391
+ isDemoted(uid, modelId, now, session) {
392
+ const e = this.demoted.get(this.sampleKey(modelId, uid, session));
301
393
  return e !== void 0 && e.until > now;
302
394
  }
303
395
  /**
@@ -323,14 +415,16 @@ var AccountPool = class {
323
415
  * 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
324
416
  *
325
417
  * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
326
- * 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
327
- * 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
418
+ * 统计按 (model, uid[, session]) 记,**不管这个号是不是「当前驻留」**——
419
+ * 亲和开启后多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
420
+ * 按会话隔离(见 sampleKey)则保证「多会话共用同号互踩」不会误伤降权。
328
421
  * @param uid 实际服务的号
329
422
  * @param modelId 模型(块粒度)
330
423
  * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
424
+ * @param session 宿主会话身份;有则按会话隔离统计
331
425
  */
332
- noteCache(uid, modelId, cachedTokens) {
333
- const k = this.key(modelId, uid);
426
+ noteCache(uid, modelId, cachedTokens, session) {
427
+ const k = this.sampleKey(modelId, uid, session);
334
428
  const s = this.samples.get(k) ?? {
335
429
  served: 0,
336
430
  hits: 0
@@ -467,11 +561,11 @@ var SupplierConfigStore = class {
467
561
  let cfg = this.bySupplier.get(supplierId);
468
562
  if (!cfg) {
469
563
  cfg = {
564
+ enabled: true,
470
565
  alias: "",
471
566
  disabled: [],
472
567
  custom: [],
473
568
  poolOrder: [],
474
- poolStrategy: "fallback",
475
569
  credits: {},
476
570
  accountNames: {}
477
571
  };
@@ -495,6 +589,22 @@ var SupplierConfigStore = class {
495
589
  this.saveLocked();
496
590
  return { ok: true };
497
591
  }
592
+ /** 这个供应商是否参与路由(未记录过 = 开)。 */
593
+ isEnabled(supplierId) {
594
+ return this.get(supplierId).enabled;
595
+ }
596
+ /**
597
+ * 置供应商开关。**不写盘**时值不变也要返回,让调用方知道是否需要重连路由。
598
+ * @returns 值确实变了
599
+ */
600
+ setEnabled(supplierId, enabled) {
601
+ if (supplierId === "") return false;
602
+ const cfg = this.get(supplierId);
603
+ if (cfg.enabled === enabled) return false;
604
+ cfg.enabled = enabled;
605
+ this.saveLocked();
606
+ return true;
607
+ }
498
608
  setModelEnabled(supplierId, modelId, enabled) {
499
609
  const cfg = this.get(supplierId);
500
610
  cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
@@ -520,11 +630,6 @@ var SupplierConfigStore = class {
520
630
  this.get(supplierId).poolOrder = [...new Set(uids)];
521
631
  this.saveLocked();
522
632
  }
523
- setPoolStrategy(supplierId, strategy) {
524
- if (strategy !== "fallback" && strategy !== "round-robin") return;
525
- this.get(supplierId).poolStrategy = strategy;
526
- this.saveLocked();
527
- }
528
633
  /** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
529
634
  getAccountName(supplierId, uid) {
530
635
  const n = this.get(supplierId).accountNames[uid];
@@ -578,11 +683,11 @@ var SupplierConfigStore = class {
578
683
  return;
579
684
  }
580
685
  for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
686
+ enabled: typeof c.enabled === "boolean" ? c.enabled : true,
581
687
  alias: typeof c.alias === "string" ? c.alias : "",
582
688
  disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
583
689
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
584
690
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
585
- poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
586
691
  credits: readCredits(c.credits),
587
692
  accountNames: readNames(c.accountNames)
588
693
  });
@@ -591,11 +696,11 @@ var SupplierConfigStore = class {
591
696
  if (this.fp === "") return;
592
697
  const f = { suppliers: {} };
593
698
  for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
699
+ enabled: cfg.enabled,
594
700
  alias: cfg.alias,
595
701
  disabled: [...cfg.disabled],
596
702
  custom: [...cfg.custom],
597
703
  poolOrder: [...cfg.poolOrder],
598
- poolStrategy: cfg.poolStrategy,
599
704
  credits: { ...cfg.credits },
600
705
  accountNames: { ...cfg.accountNames }
601
706
  };
@@ -889,6 +994,16 @@ var UsageStore = class UsageStore {
889
994
  recentList(limit = 20) {
890
995
  return this.recent.slice(0, limit);
891
996
  }
997
+ /**
998
+ * 最近一次命中。给了 `session` 就只在该会话的记录里找(「最近命中」徽章
999
+ * 要显示**当前会话**的命中,team 多会话下全局最近会张冠李戴);
1000
+ * 不给则退回全局最近一条。
1001
+ * @param session 宿主会话身份;undefined = 全局
1002
+ */
1003
+ lastHit(session) {
1004
+ if (session === void 0) return this.recent[0];
1005
+ return this.recent.find((r) => r.session === session);
1006
+ }
892
1007
  clear() {
893
1008
  this.days.clear();
894
1009
  this.recent = [];
@@ -1302,8 +1417,8 @@ function withEstimates(usage, requestChars, outputChars) {
1302
1417
  //#region src/router/index.ts
1303
1418
  /**
1304
1419
  * dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
1305
- * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
1306
- * (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
1420
+ * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,作为降级链
1421
+ * 按序尝试选中一个模型路由(无策略旋钮,见 docs/pool-sticky-block.md §10)。
1307
1422
  */
1308
1423
  /**
1309
1424
  * 模型列表缓存有效期。
@@ -1357,6 +1472,99 @@ function isTransient(state) {
1357
1472
  return state === "transport" || state === "unavailable";
1358
1473
  }
1359
1474
  /**
1475
+ * **每条降级腿的首字节预算**:从进入这条腿起,多久内上游必须吐出第一个字节。
1476
+ *
1477
+ * 为什么必须有(2026-09-24 夜里 money 组合实测):上游(codebuddy 系)额度用完 /
1478
+ * 过载时,连接照常建立、HTTP 200 响应头照常回来,**body 却一个字节都不吐**。
1479
+ * 这既不是成功也不是失败,于是
1480
+ * - `chatOnce` 的 120s 只守「连接+响应头」,`fetch` 一返回就撤表 → 守不到 body
1481
+ * - 核心写响应的路径也没有任何计时器 → 组合链卡在这一条腿上不动
1482
+ * - 客户端(dsh-router 自己的 adapter)有 300s 流空闲超时,但那是「整条请求」
1483
+ * 的兜底,与降级无关
1484
+ * 实测用户干等 200 多秒手动暂停:ttfb 84s / 122s / 151s / 244s / 246s / 250s /
1485
+ * 254s / 255s,8 次全部 `ok: true`——**全都最终成功了**,只是晚了四分钟。同一批
1486
+ * 账号因为「没失败」也不冷却,下一次请求继续挑它(cb-8 连吃 248s + 155s)。
1487
+ *
1488
+ * 60s 的来历(本机 500 条真实请求的 ttfb 分布):p50 4.1s / p95 14.9s,
1489
+ * 最大的**非停滞**值 47.7s,停滞簇从 84.5s 起 —— 60s 正落在两簇之间的空档,
1490
+ * 砍得动停滞、砍不到真实慢请求。首个字节之后**不再计时**:长生成可以跑很久。
1491
+ *
1492
+ * 为什么按「腿」而不是按「账号」计:停滞是上游级的(同一供应商的所有号一起
1493
+ * 挂)。按账号计的话,codebuddy 有 11 个号,一次请求能 11×60s = 11 分钟才轮到
1494
+ * 下一个模型;按腿计,最坏 3 条腿 × 60s ≈ 3 分钟,且期间一定会走到下一个模型。
1495
+ */
1496
+ const FIRST_BYTE_BUDGET_MS = 6e4;
1497
+ /**
1498
+ * 给上游流套一层**首字节截止**:截止前一个字节都没来,就把这条流判失败
1499
+ * (error 出去,调用方看到「一个字节都没写」→ 换号/换模型)。
1500
+ * 第一个字节到手后立刻撤表,之后多久都不管。
1501
+ *
1502
+ * 为什么套在核心而不是插件:首字节迟迟不来时,**只有核心**能做「还没提交响应
1503
+ * → 换一条腿」的决定(插件在 `fetch` 拿到响应头那一刻就把流交出去了,
1504
+ * 谁也没法再改结果)。核心也是所有供应商唯一的收口点,套一次全都受益。
1505
+ */
1506
+ function withFirstByteDeadline(stream, ms) {
1507
+ const reader = stream.getReader();
1508
+ let first = true;
1509
+ /** 消费方已取消:这之后任何 ctrl.* 都会抛(流已死),必须全部跳过。 */
1510
+ let cancelled = false;
1511
+ /**
1512
+ * 上游 reader 只解绑一次。wrapper 替核心持有了这个 reader,所以**必须由
1513
+ * wrapper 负责收尾**:上游正常读完 / 上游自己 error / 被取消时都解锁,
1514
+ * 否则流永久锁着,上层想 cancel 会抛 `Invalid state: ReadableStream is locked`
1515
+ * (disconnect-leak 那条判据守的就是这个;历史上由 aggregateSSE 的 finally 解锁,
1516
+ * 现在读的是 wrapper,解绑责任跟着搬过来)。
1517
+ */
1518
+ let released = false;
1519
+ const release = () => {
1520
+ if (released) return;
1521
+ released = true;
1522
+ try {
1523
+ reader.releaseLock();
1524
+ } catch {}
1525
+ };
1526
+ /** 取消要传到底下的上游(cancel 本身会顺带解锁)。 */
1527
+ const cancelUpstream = (reason) => {
1528
+ if (released) return Promise.resolve();
1529
+ released = true;
1530
+ return reader.cancel(reason).then(() => {}, () => {});
1531
+ };
1532
+ return new ReadableStream({
1533
+ async pull(ctrl) {
1534
+ let expired = false;
1535
+ const timer = first ? setTimeout(() => {
1536
+ expired = true;
1537
+ cancelUpstream(/* @__PURE__ */ new Error("first byte timeout"));
1538
+ try {
1539
+ ctrl.error(/* @__PURE__ */ new Error(`no first byte within ${ms}ms`));
1540
+ } catch {}
1541
+ }, ms) : void 0;
1542
+ try {
1543
+ const { done, value } = await reader.read();
1544
+ if (expired || cancelled) return;
1545
+ if (done) {
1546
+ release();
1547
+ ctrl.close();
1548
+ return;
1549
+ }
1550
+ first = false;
1551
+ ctrl.enqueue(value);
1552
+ } catch (e) {
1553
+ release();
1554
+ if (!expired && !cancelled) try {
1555
+ ctrl.error(e);
1556
+ } catch {}
1557
+ } finally {
1558
+ if (timer !== void 0) clearTimeout(timer);
1559
+ }
1560
+ },
1561
+ cancel: (reason) => {
1562
+ cancelled = true;
1563
+ return cancelUpstream(reason);
1564
+ }
1565
+ });
1566
+ }
1567
+ /**
1360
1568
  * 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
1361
1569
  * undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
1362
1570
  */
@@ -1627,8 +1835,11 @@ function fixFrame(frame) {
1627
1835
  *
1628
1836
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
1629
1837
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
1838
+ *
1839
+ * @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
1840
+ * 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
1630
1841
  */
1631
- async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1842
+ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0, firstByteTimeoutMs = FIRST_BYTE_BUDGET_MS) {
1632
1843
  if (!r.ok) return false;
1633
1844
  if (!("stream" in r)) {
1634
1845
  if (probe !== void 0) probe.tokens = usageFromJsonBody(r.body);
@@ -1639,12 +1850,13 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1639
1850
  res.end(r.body);
1640
1851
  return true;
1641
1852
  }
1853
+ const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs);
1642
1854
  if (!wantsStream) {
1643
1855
  let body;
1644
1856
  try {
1645
- body = await aggregateSSE(r.stream);
1857
+ body = await aggregateSSE(src);
1646
1858
  } catch {
1647
- await r.stream.cancel().catch(() => {});
1859
+ await src.cancel().catch(() => {});
1648
1860
  return false;
1649
1861
  }
1650
1862
  if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
@@ -1655,7 +1867,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1655
1867
  res.end(body);
1656
1868
  return true;
1657
1869
  }
1658
- const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
1870
+ const tapped = probe === void 0 ? void 0 : tapStreamUsage(src, startedAt);
1659
1871
  let wroteAny = false;
1660
1872
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
1661
1873
  let dead = false;
@@ -1719,7 +1931,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1719
1931
  abort.abort();
1720
1932
  };
1721
1933
  res.once("close", onClose);
1722
- const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
1934
+ const norm = normalizeSSEStream(tapped?.stream ?? src);
1723
1935
  try {
1724
1936
  await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
1725
1937
  } catch {} finally {
@@ -1750,12 +1962,16 @@ function extractUpstreamError(body) {
1750
1962
  }
1751
1963
  /** 路由器。 */
1752
1964
  var Router = class {
1965
+ /**
1966
+ * **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
1967
+ * 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
1968
+ *
1969
+ * 真正参与路由的只有 `active` 那一份。
1970
+ */
1753
1971
  suppliers = [];
1754
1972
  combosFp = "";
1755
1973
  customCombos = [];
1756
- /** round-robin 轮转游标(按组合 id 记忆)。 */
1757
- rrCursors = /* @__PURE__ */ new Map();
1758
- /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
1974
+ /** 通用供应商配置(连接池顺序、模型启用、别名)。 */
1759
1975
  store;
1760
1976
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
1761
1977
  modelsCache = /* @__PURE__ */ new Map();
@@ -1766,12 +1982,15 @@ var Router = class {
1766
1982
  modelsInflight = /* @__PURE__ */ new Map();
1767
1983
  /** 请求日志出口(面板/宿主 logger)。 */
1768
1984
  log;
1985
+ /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
1986
+ firstByteBudgetMs;
1769
1987
  /** 用量统计(概览看板)。 */
1770
1988
  usage;
1771
- constructor(stateFile = "", store, log) {
1989
+ constructor(stateFile = "", store, log, opts) {
1772
1990
  this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
1773
1991
  this.store = store ?? new SupplierConfigStore(stateFile);
1774
1992
  this.log = log ?? (() => {});
1993
+ this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS;
1775
1994
  this.usage = new UsageStore(stateFile);
1776
1995
  this.loadCombos();
1777
1996
  }
@@ -1795,13 +2014,12 @@ var Router = class {
1795
2014
  if (slash <= 0) return m;
1796
2015
  const alias = m.slice(0, slash);
1797
2016
  const modelId = m.slice(slash + 1);
1798
- const s = this.suppliers.find((x) => x.getAlias() === alias);
2017
+ const s = this.active.find((x) => x.getAlias() === alias);
1799
2018
  return s === void 0 ? modelId : `${s.id},${modelId}`;
1800
2019
  });
1801
2020
  return {
1802
2021
  id: c.id,
1803
2022
  name: typeof c.name === "string" ? c.name : c.id,
1804
- strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
1805
2023
  models
1806
2024
  };
1807
2025
  }).filter((c) => c.models.length > 0);
@@ -1823,6 +2041,20 @@ var Router = class {
1823
2041
  this.suppliers.sort((a, b) => a.priority - b.priority);
1824
2042
  this.store.sync(this.suppliers.map((s) => s.id));
1825
2043
  }
2044
+ /**
2045
+ * **参与路由的供应商** —— 装载清单里开关为开的那一份,按 priority 排好。
2046
+ *
2047
+ * 这是「供应商开关」唯一的收口点:请求分发、别名反查、模型列表、组合、面板状态
2048
+ * 全部读它。关掉一个供应商 = 它从这里消失,而不是在每个调用点各写一遍判断 ——
2049
+ * 后者一定会漏(本次实测路由有 15 处读 `this.suppliers`)。
2050
+ */
2051
+ get active() {
2052
+ return this.suppliers.filter((s) => this.store.isEnabled(s.id));
2053
+ }
2054
+ /** 某供应商当前是否参与路由(面板与 API 用)。 */
2055
+ isEnabled(supplierId) {
2056
+ return this.store.isEnabled(supplierId);
2057
+ }
1826
2058
  /** 移除供应商(外部插件卸载时注销)。 */
1827
2059
  removeSupplier(id) {
1828
2060
  const i = this.suppliers.findIndex((s) => s.id === id);
@@ -1832,13 +2064,20 @@ var Router = class {
1832
2064
  s?.dispose();
1833
2065
  return true;
1834
2066
  }
1835
- /** 返回全部供应商状态(面板用)。 */
2067
+ /**
2068
+ * 返回全部供应商状态(面板用)。**含被开关关掉的** —— 面板要能显示它们
2069
+ * (「已关闭」),否则用户在面板上就再也看不到自己装过的供应商。
2070
+ * 开关状态由 `enabled` 字段带出,调用方自己决定要不要只留开着的。
2071
+ */
1836
2072
  status() {
1837
- return { suppliers: this.suppliers.map((s) => s.status()) };
2073
+ return { suppliers: this.suppliers.map((s) => ({
2074
+ ...s.status(),
2075
+ enabled: this.store.isEnabled(s.id)
2076
+ })) };
1838
2077
  }
1839
- /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
2078
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
1840
2079
  aliases() {
1841
- return this.suppliers.map((s) => ({
2080
+ return this.active.map((s) => ({
1842
2081
  id: s.id,
1843
2082
  name: s.name,
1844
2083
  alias: s.getAlias()
@@ -1856,7 +2095,7 @@ var Router = class {
1856
2095
  seen.add(c.name);
1857
2096
  out.push({ id: c.name });
1858
2097
  }
1859
- for (const s of this.suppliers) try {
2098
+ for (const s of this.active) try {
1860
2099
  const ids = s.customModelIds?.();
1861
2100
  for (const id of ids ?? []) if (!seen.has(id)) {
1862
2101
  seen.add(id);
@@ -1915,7 +2154,7 @@ var Router = class {
1915
2154
  return min;
1916
2155
  }
1917
2156
  async modelsOf(supplierId, force = false) {
1918
- const s = this.suppliers.find((x) => x.id === supplierId);
2157
+ const s = this.active.find((x) => x.id === supplierId);
1919
2158
  if (s === void 0) return [];
1920
2159
  const hit = this.modelsCache.get(supplierId);
1921
2160
  const fresh = hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS;
@@ -1979,7 +2218,7 @@ var Router = class {
1979
2218
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
1980
2219
  * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
1981
2220
  async supplierModels() {
1982
- return (await Promise.all(this.suppliers.map(async (s) => {
2221
+ return (await Promise.all(this.active.map(async (s) => {
1983
2222
  try {
1984
2223
  const models = (await this.modelsOf(s.id)).filter((m) => m.enabled);
1985
2224
  return {
@@ -1998,9 +2237,6 @@ var Router = class {
1998
2237
  validModels(models) {
1999
2238
  return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
2000
2239
  }
2001
- validStrategy(strategy) {
2002
- return strategy === "fallback" || strategy === "round-robin";
2003
- }
2004
2240
  /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
2005
2241
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
2006
2242
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
@@ -2017,12 +2253,12 @@ var Router = class {
2017
2253
  if (slash <= 0) return m;
2018
2254
  const alias = m.slice(0, slash);
2019
2255
  const modelId = m.slice(slash + 1);
2020
- const s = this.suppliers.find((x) => x.getAlias() === alias);
2256
+ const s = this.active.find((x) => x.getAlias() === alias);
2021
2257
  return s === void 0 ? modelId : `${s.id},${modelId}`;
2022
2258
  });
2023
2259
  }
2024
- /** 创建组合(name 唯一,非 default)。 */
2025
- createCombo(name, strategy, models) {
2260
+ /** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
2261
+ createCombo(name, models) {
2026
2262
  const clean = name.trim();
2027
2263
  if (clean === "" || clean === "default") return {
2028
2264
  ok: false,
@@ -2040,14 +2276,9 @@ var Router = class {
2040
2276
  ok: false,
2041
2277
  error: "至少需要一个模型"
2042
2278
  };
2043
- if (!this.validStrategy(strategy)) return {
2044
- ok: false,
2045
- error: "策略无效"
2046
- };
2047
2279
  const combo = {
2048
2280
  id: clean,
2049
2281
  name: clean,
2050
- strategy,
2051
2282
  models: this.normalizeModelIds(models)
2052
2283
  };
2053
2284
  this.customCombos.push(combo);
@@ -2058,7 +2289,7 @@ var Router = class {
2058
2289
  };
2059
2290
  }
2060
2291
  /** 更新组合(按 id)。 */
2061
- updateCombo(id, name, strategy, models) {
2292
+ updateCombo(id, name, models) {
2062
2293
  const target = this.customCombos.find((c) => c.id === id);
2063
2294
  if (!target) return {
2064
2295
  ok: false,
@@ -2081,12 +2312,7 @@ var Router = class {
2081
2312
  ok: false,
2082
2313
  error: "至少需要一个模型"
2083
2314
  };
2084
- if (!this.validStrategy(strategy)) return {
2085
- ok: false,
2086
- error: "策略无效"
2087
- };
2088
2315
  target.name = clean;
2089
- target.strategy = strategy;
2090
2316
  target.models = this.normalizeModelIds(models);
2091
2317
  this.saveCombos();
2092
2318
  return { ok: true };
@@ -2143,18 +2369,17 @@ var Router = class {
2143
2369
  durationMs: Date.now() - probe.startedAt,
2144
2370
  ttfbMs: probe.ttfbMs,
2145
2371
  ...ok && probe.uid !== "" ? { uid: probe.uid } : {},
2372
+ ...req.session === void 0 ? {} : { session: req.session },
2146
2373
  ...error === void 0 ? {} : { error }
2147
2374
  }, usage);
2148
2375
  };
2149
2376
  const combo = this.comboByName(req.model);
2150
2377
  if (combo) {
2151
- const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
2152
- if (combo.strategy === "round-robin") this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1);
2153
2378
  for (let i = 0; i < combo.models.length; i++) {
2154
- const model = combo.models[(start + i) % combo.models.length];
2379
+ const model = combo.models[i];
2155
2380
  if (model === void 0) continue;
2156
2381
  const trace = { attempts: 0 };
2157
- if (await this.chatWithModel(req, res, model, probe, trace)) {
2382
+ if (await this.chatWithModel(req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs)) {
2158
2383
  settle(true);
2159
2384
  return;
2160
2385
  }
@@ -2200,8 +2425,11 @@ var Router = class {
2200
2425
  * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
2201
2426
  * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
2202
2427
  * 失败状态没有单一归属)。
2428
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
2429
+ * 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
2430
+ * 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
2203
2431
  */
2204
- async chatWithModel(req, res, model, probe, outTrace) {
2432
+ async chatWithModel(req, res, model, probe, outTrace, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs) {
2205
2433
  const comma = model.indexOf(",");
2206
2434
  const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
2207
2435
  const modelId = comma > 0 ? model.slice(comma + 1) : model;
@@ -2221,25 +2449,26 @@ var Router = class {
2221
2449
  this.logChat(req.model, msg, 0);
2222
2450
  return false;
2223
2451
  }
2224
- const s = this.suppliers.find((x) => x.id === supplierId);
2452
+ const s = this.active.find((x) => x.id === supplierId);
2225
2453
  if (s === void 0) {
2226
- this.logChat(req.model, `supplier ${JSON.stringify(supplierId)} 不存在`, 0);
2454
+ const reason = this.suppliers.some((x) => x.id === supplierId) ? `supplier ${JSON.stringify(supplierId)} 已关闭` : `supplier ${JSON.stringify(supplierId)} 不存在`;
2455
+ this.logChat(req.model, reason, 0);
2227
2456
  return false;
2228
2457
  }
2229
2458
  const t0 = Date.now();
2230
2459
  const trace = { attempts: 0 };
2231
- const served = await this.chatWithSupplier(s, clone, res, trace, probe);
2460
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt);
2232
2461
  if (outTrace !== void 0) {
2233
2462
  outTrace.attempts = trace.attempts;
2234
2463
  outTrace.lastError = trace.lastError;
2235
2464
  outTrace.lastState = trace.lastState;
2236
2465
  }
2237
- this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
2466
+ this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}${trace.pickWhy !== void 0 ? ` [选号: ${trace.pickWhy}]` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
2238
2467
  return served;
2239
2468
  }
2240
2469
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
2241
2470
  supplierByAlias(alias) {
2242
- return this.suppliers.find((s) => s.getAlias() === alias);
2471
+ return this.active.find((s) => s.getAlias() === alias);
2243
2472
  }
2244
2473
  /**
2245
2474
  * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
@@ -2249,8 +2478,16 @@ var Router = class {
2249
2478
  * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
2250
2479
  * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
2251
2480
  * 延迟,目前未做。
2481
+ *
2482
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
2483
+ * 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
2484
+ * 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
2485
+ */
2486
+ /**
2487
+ * @param onlyUid - 只试这一个连接,**不做账号池回退**。给面板「指定连接」那档用
2488
+ * (诊断单个链接到底通不通);正常请求与普通模型测试都不传,走账号池。
2252
2489
  */
2253
- async chatWithSupplier(s, req, res, trace, probe) {
2490
+ async chatWithSupplier(s, req, res, trace, probe, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs, onlyUid) {
2254
2491
  const pool = s.pool;
2255
2492
  const cfg = this.store.get(s.id);
2256
2493
  if (s.accounts !== void 0 && s.accounts().length === 0) {
@@ -2267,13 +2504,28 @@ var Router = class {
2267
2504
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2268
2505
  probe.uid = "";
2269
2506
  }
2270
- return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
2507
+ return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now());
2271
2508
  }
2272
2509
  const messages = reqMessages(req.rawBody);
2273
2510
  const tried = /* @__PURE__ */ new Set();
2511
+ let pickWhy = "";
2274
2512
  for (;;) {
2275
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages);
2276
- if (uid === void 0) return false;
2513
+ const uid = onlyUid !== void 0 ? s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : void 0 : pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
2514
+ if (uid === void 0) {
2515
+ if (onlyUid !== void 0 && trace !== void 0) {
2516
+ trace.lastState = "unknown";
2517
+ trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`;
2518
+ }
2519
+ return false;
2520
+ }
2521
+ if (firstByteDeadlineAt - Date.now() <= 0) {
2522
+ if (trace !== void 0) {
2523
+ trace.lastState = "transport";
2524
+ trace.lastError = "first-byte budget exhausted";
2525
+ }
2526
+ return false;
2527
+ }
2528
+ pickWhy = pool.lastWhy;
2277
2529
  tried.add(uid);
2278
2530
  const r = await s.chatOnce(uid, req.lv ?? "auto", req);
2279
2531
  if (!r.ok) {
@@ -2294,14 +2546,17 @@ var Router = class {
2294
2546
  continue;
2295
2547
  }
2296
2548
  pool.noteSuccess(uid, req.model);
2297
- if (trace !== void 0) trace.uid = uid;
2549
+ if (trace !== void 0) {
2550
+ trace.uid = uid;
2551
+ trace.pickWhy = pickWhy;
2552
+ }
2298
2553
  if (probe !== void 0) {
2299
2554
  probe.supplier = s.id;
2300
2555
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2301
2556
  probe.uid = uid;
2302
2557
  }
2303
- if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
2304
- pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
2558
+ if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now())) {
2559
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
2305
2560
  return true;
2306
2561
  }
2307
2562
  pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
@@ -2312,11 +2567,17 @@ var Router = class {
2312
2567
  }
2313
2568
  }
2314
2569
  }
2315
- /** 测试某供应商的某模型是否可用。
2316
- * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
2317
- * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
2318
- async testModel(supplierId, model) {
2319
- const s = this.suppliers.find((x) => x.id === supplierId);
2570
+ /**
2571
+ * 测试某供应商的某模型是否可用。
2572
+ *
2573
+ * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
2574
+ * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。
2575
+ *
2576
+ * @param onlyUid - 限定只测这一个连接(诊断单个链接),**不做账号池回退**。
2577
+ * 不传 = 与面板「测试」按钮完全同一条路径(池内任选可用号,失败自动换号)。
2578
+ */
2579
+ async testModel(supplierId, model, onlyUid) {
2580
+ const s = this.active.find((x) => x.id === supplierId);
2320
2581
  if (s === void 0) return {
2321
2582
  ok: false,
2322
2583
  error: `unknown supplier ${JSON.stringify(supplierId)}`
@@ -2338,7 +2599,7 @@ var Router = class {
2338
2599
  let served = false;
2339
2600
  const trace = { attempts: 0 };
2340
2601
  try {
2341
- served = await this.chatWithSupplier(s, req, sink, trace);
2602
+ served = await this.chatWithSupplier(s, req, sink, trace, void 0, Date.now() + this.firstByteBudgetMs, onlyUid);
2342
2603
  } catch (err) {
2343
2604
  return {
2344
2605
  ok: false,
@@ -2371,8 +2632,43 @@ var Router = class {
2371
2632
  * `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
2372
2633
  * 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
2373
2634
  * 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
2374
- * 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
2635
+ * 图片字节从 `ctx.attachments` 读取。读不到时按稳定占位文本降级(不让整条消息失败),
2636
+ * 但**必须留痕**:降级原因走 adapter 的 `log` 出口打到宿主 logger。曾经这里是
2637
+ * 一个空 `catch {}`,字段改名导致的读图失败静默了跨两个内测版本(issue #8)。
2638
+ */
2639
+ /** 送上游的请求图的像素预算(宽 × 高),沿用 0.1.5 时代 `maxPixels` 的意图。 */
2640
+ const MAX_REQUEST_PIXELS = 64e4;
2641
+ /** 送上游的请求图的字节上限。 */
2642
+ const MAX_REQUEST_BYTES = 1048576;
2643
+ /**
2644
+ * 把源图尺寸投影到「总像素 ≤ maxPixels」的请求目标(等比、只缩不放)。
2645
+ *
2646
+ * 为什么需要它:宿主 0.1.6 起要的是 `ImageRequestTarget`(width/height/maxBytes),
2647
+ * 而「别把一张 12MP 手机照原样塞进对话」这个意图原本靠 `maxPixels` 表达。
2648
+ * 宿主自己**只按 target 的长边**缩放、短边由它推,所以我们要做的只是给出一组
2649
+ * 落在预算内的正整数;短边取 floor 即可(它只参与校验与缓存键,不决定出图尺寸)。
2650
+ *
2651
+ * 没有直接复用官方的 `requestImageDimensions`(`@deepseek-ai/dsh-attachment` 有导出):
2652
+ * 本文件刻意不依赖那个包(见 `RouterAttachmentStore` 的说明 —— 它是宿主注入的
2653
+ * devDependency,引入它等于给插件加一条编译期/运行期依赖)。这 8 行是纯函数、
2654
+ * 有测试钉住;真要换成官方实现时,语义等价。
2375
2655
  */
2656
+ function projectImageDimensions(width, height, maxPixels) {
2657
+ const pixels = width * height;
2658
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return {
2659
+ width,
2660
+ height
2661
+ };
2662
+ if (pixels <= maxPixels) return {
2663
+ width,
2664
+ height
2665
+ };
2666
+ const scale = Math.sqrt(maxPixels / pixels);
2667
+ return {
2668
+ width: Math.max(1, Math.floor(width * scale)),
2669
+ height: Math.max(1, Math.floor(height * scale))
2670
+ };
2671
+ }
2376
2672
  /**
2377
2673
  * 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
2378
2674
  *
@@ -2390,6 +2686,10 @@ var Router = class {
2390
2686
  * 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
2391
2687
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
2392
2688
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
2689
+ *
2690
+ * 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
2691
+ * (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
2692
+ * 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
2393
2693
  */
2394
2694
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2395
2695
  const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
@@ -2403,7 +2703,7 @@ const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
2403
2703
  * 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
2404
2704
  */
2405
2705
  const STREAM_IDLE_TIMEOUT_MS = 3e5;
2406
- async function wireMessages(options, system, attachments) {
2706
+ async function wireMessages(options, system, attachments, log) {
2407
2707
  const out = [];
2408
2708
  /**
2409
2709
  * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
@@ -2461,8 +2761,21 @@ async function wireMessages(options, system, attachments) {
2461
2761
  });
2462
2762
  continue;
2463
2763
  }
2764
+ if (message.role === "tool") {
2765
+ const toolCallId = message.toolCallId;
2766
+ const parts = await contentParts(message.content, attachments, options.signal, log);
2767
+ const imageParts = parts.filter((p) => p.type !== "text");
2768
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2769
+ out.push({
2770
+ role: "tool",
2771
+ tool_call_id: String(toolCallId ?? ""),
2772
+ content: text
2773
+ });
2774
+ pendingToolImages.push(...imageParts);
2775
+ continue;
2776
+ }
2464
2777
  const toolResults = message.content.filter((b) => b.type === "tool-result");
2465
- const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
2778
+ const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal, log);
2466
2779
  if (ownParts.length > 0 || toolResults.length === 0) {
2467
2780
  flushToolImages();
2468
2781
  out.push({
@@ -2471,7 +2784,7 @@ async function wireMessages(options, system, attachments) {
2471
2784
  });
2472
2785
  }
2473
2786
  for (const result of toolResults) {
2474
- const parts = await contentParts(result.content, attachments, options.signal);
2787
+ const parts = await contentParts(result.content, attachments, options.signal, log);
2475
2788
  const imageParts = parts.filter((p) => p.type !== "text");
2476
2789
  const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2477
2790
  out.push({
@@ -2502,7 +2815,7 @@ function compactUserContent(parts) {
2502
2815
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
2503
2816
  * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
2504
2817
  */
2505
- async function contentParts(blocks, attachments, signal) {
2818
+ async function contentParts(blocks, attachments, signal, log) {
2506
2819
  const parts = [];
2507
2820
  for (const block of blocks) {
2508
2821
  if (block.type === "text") {
@@ -2515,35 +2828,41 @@ async function contentParts(blocks, attachments, signal) {
2515
2828
  }
2516
2829
  if (block.type === "image") {
2517
2830
  const ref = block.attachment;
2518
- parts.push(...await imageParts(ref, attachments, signal));
2831
+ parts.push(...await imageParts(ref, attachments, signal, log));
2519
2832
  continue;
2520
2833
  }
2521
- if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal));
2834
+ if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal, log));
2522
2835
  }
2523
2836
  return parts;
2524
2837
  }
2525
2838
  /** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
2526
- async function imageParts(ref, attachments, signal) {
2527
- if (attachments === void 0) return [{
2528
- type: "text",
2529
- text: imagePlaceholder(ref.attachmentId)
2530
- }];
2839
+ async function imageParts(ref, attachments, signal, log) {
2840
+ if (attachments === void 0) {
2841
+ log?.("图片附件 store 不可用(ctx.attachments 未注入),该图降级为占位文本");
2842
+ return [{
2843
+ type: "text",
2844
+ text: imagePlaceholder(ref.attachmentId)
2845
+ }];
2846
+ }
2847
+ const ref0 = {
2848
+ attachmentId: ref.attachmentId,
2849
+ mediaType: ref.mediaType,
2850
+ bytes: ref.bytes,
2851
+ width: ref.width,
2852
+ height: ref.height
2853
+ };
2854
+ const target = {
2855
+ ...projectImageDimensions(ref.width, ref.height, MAX_REQUEST_PIXELS),
2856
+ maxBytes: MAX_REQUEST_BYTES
2857
+ };
2531
2858
  try {
2532
- const img = await attachments.readImageRequest({
2533
- attachmentId: ref.attachmentId,
2534
- mediaType: ref.mediaType,
2535
- bytes: ref.bytes,
2536
- width: ref.width,
2537
- height: ref.height
2538
- }, {
2539
- maxPixels: 64e4,
2540
- maxBytes: 1048576
2541
- }, signal);
2859
+ const img = await attachments.readImageRequest(ref0, target, signal);
2542
2860
  return [{
2543
2861
  type: "image_url",
2544
2862
  image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` }
2545
2863
  }];
2546
- } catch {
2864
+ } catch (err) {
2865
+ log?.(`读取图片 ${ref.attachmentId} 失败,已降级为占位文本:${err.message}`);
2547
2866
  return [{
2548
2867
  type: "text",
2549
2868
  text: imagePlaceholder(ref.attachmentId)
@@ -2562,8 +2881,8 @@ function imagePlaceholder(attachmentId) {
2562
2881
  return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`;
2563
2882
  }
2564
2883
  /** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
2565
- async function wireRequest(options, attachments) {
2566
- const messages = await wireMessages(options, options.system, attachments);
2884
+ async function wireRequest(options, attachments, log) {
2885
+ const messages = await wireMessages(options, options.system, attachments, log);
2567
2886
  const body = {
2568
2887
  model: options.model,
2569
2888
  messages,
@@ -2846,16 +3165,21 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2846
3165
  resolveAttachments;
2847
3166
  /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
2848
3167
  idleTimeoutMs;
3168
+ /** 降级/失败诊断的出口(宿主接 ctx.logger.warn)。缺省丢弃 —— 仅为测试可注入。 */
3169
+ log;
2849
3170
  /**
2850
3171
  * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
2851
3172
  * 可注入只为测试能压到几百毫秒,生产不传。
3173
+ * @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
3174
+ * 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
2852
3175
  */
2853
- constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
3176
+ constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS, log) {
2854
3177
  super();
2855
3178
  this.baseURL = baseURL;
2856
3179
  this.source = source;
2857
3180
  this.resolveAttachments = resolveAttachments ?? (() => void 0);
2858
3181
  this.idleTimeoutMs = idleTimeoutMs;
3182
+ this.log = log;
2859
3183
  }
2860
3184
  providerInfo(provider) {
2861
3185
  return {
@@ -2902,7 +3226,7 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2902
3226
  };
2903
3227
  }
2904
3228
  async *stream(options) {
2905
- const body = await wireRequest(options, this.resolveAttachments());
3229
+ const body = await wireRequest(options, this.resolveAttachments(), this.log);
2906
3230
  const controller = new AbortController();
2907
3231
  const onAbort = () => controller.abort();
2908
3232
  options.signal?.addEventListener("abort", onAbort, { once: true });
@@ -2913,7 +3237,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2913
3237
  headers: {
2914
3238
  ...attributionHeaders(),
2915
3239
  "content-type": "application/json",
2916
- accept: "text/event-stream"
3240
+ accept: "text/event-stream",
3241
+ ...options.sessionId === void 0 ? {} : { "x-dsh-router-session": String(options.sessionId) }
2917
3242
  },
2918
3243
  body: JSON.stringify(body),
2919
3244
  signal: controller.signal
@@ -3141,12 +3466,18 @@ var KeysStore = class {
3141
3466
  //#endregion
3142
3467
  //#region src/suppliers/loader.ts
3143
3468
  /**
3144
- * 供应商 loader —— 扫描 suppliers 目录下的 *.js,动态 import,按契约包装成
3469
+ * 供应商 loader —— 扫描 **用户目录**下的 *.js,动态 import,按契约包装成
3145
3470
  * 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
3146
3471
  *
3147
- * 加载来源(两处,profile 优先):
3148
- * 1. 内置:<plugin>/lib/suppliers/*.js(随插件分发)
3149
- * 2. 用户:~/.dsh/profiles/web/suppliers/*.js(用户自定义)
3472
+ * 加载来源(两处):
3473
+ * 1. 用户:`~/.dsh/profiles/<profile>/suppliers/*.js`(自定义供应商)
3474
+ * 2. 内置:**插件页「包含的组件」里的行** —— core 自己的 cordis.patch.yml
3475
+ * insert 了三个子路径模块,经 `router.suppliers` 表进来,与独立安装的
3476
+ * 供应商插件同一条通道,核心不区分内外,只看工厂上的 `source` 标签。
3477
+ *
3478
+ * 内置曾经也是「扫 `<plugin>/lib/suppliers/*.js`」(本文件早先的 builtinDir)。
3479
+ * 改成行之后那条路径已删:扫目录拿不到行的身份(插件页的开关/显示名/说明),
3480
+ * 而且关一个内置供应商要手改配置才生效 —— 行才有宿主管的开关。
3150
3481
  */
3151
3482
  function isFn(v) {
3152
3483
  return typeof v === "function";
@@ -3282,7 +3613,27 @@ function scanDir(dir) {
3282
3613
  return [];
3283
3614
  }
3284
3615
  }
3285
- /** 加载全部供应商(内置 + 用户,用户覆盖内置同 id)。 */
3616
+ /**
3617
+ * 同 id 冲突时的优先级:**用户目录 js > 内置行 > 外部插件供应商**。
3618
+ *
3619
+ * 为什么用户目录最高:用户放一个同 id 的 js 就是想替换它,这是「扫目录时代」
3620
+ * 就有的语义(见本文件早先的 `loadSuppliers`:内置被用户版覆盖),内置改成行
3621
+ * 之后不能悄悄反过来。
3622
+ *
3623
+ * 纯函数 + 有测试:这条规则错了不会抛异常,只会让用户的覆盖悄悄失效。
3624
+ */
3625
+ const SUPPLIER_RANK = {
3626
+ user: 2,
3627
+ builtin: 1,
3628
+ external: 0
3629
+ };
3630
+ function supplierWins(incoming, existing) {
3631
+ return SUPPLIER_RANK[incoming] > SUPPLIER_RANK[existing];
3632
+ }
3633
+ /**
3634
+ * 加载用户目录下的供应商。同一个 id 出现多个文件时**后者覆盖前者**(按文件名序),
3635
+ * 与内置共存的裁决交给核心(`registerLoaded` 的优先级:用户目录 js > 行)。
3636
+ */
3286
3637
  async function loadSuppliers(opts) {
3287
3638
  const env = {
3288
3639
  dataDir: opts.dataDir,
@@ -3292,18 +3643,10 @@ async function loadSuppliers(opts) {
3292
3643
  };
3293
3644
  const byId = /* @__PURE__ */ new Map();
3294
3645
  const errors = [];
3295
- const files = [...scanDir(opts.builtinDir).map((f) => ({
3296
- f,
3297
- builtin: true
3298
- })), ...scanDir(opts.userDir).map((f) => ({
3299
- f,
3300
- builtin: false
3301
- }))];
3302
- for (const { f, builtin } of files) try {
3303
- const loaded = await loadOne(f, env, builtin ? "builtin" : "user");
3304
- if (byId.get(loaded.supplier.id) && builtin) continue;
3646
+ for (const f of scanDir(opts.userDir).sort()) try {
3647
+ const loaded = await loadOne(f, env, "user");
3305
3648
  byId.set(loaded.supplier.id, loaded);
3306
- opts.log(`supplier ${loaded.supplier.id} loaded (${builtin ? "builtin" : "user"}) from ${basename(f)}`);
3649
+ opts.log(`supplier ${loaded.supplier.id} loaded (user) from ${basename(f)}`);
3307
3650
  } catch (err) {
3308
3651
  errors.push({
3309
3652
  id: basename(f, ".js"),
@@ -3318,6 +3661,355 @@ async function loadSuppliers(opts) {
3318
3661
  };
3319
3662
  }
3320
3663
  //#endregion
3664
+ //#region src/suppliers/probe.ts
3665
+ /**
3666
+ * 契约成员清单(体检的唯一事实来源)。
3667
+ *
3668
+ * `required: false` = 差异化能力,按存在性暴露端点/UI;`probe: 'skip'` 的理由都写在这里,
3669
+ * 面板会把 `skipReason` 原样显示 —— 用户能知道「有这个能力,只是体检不替你调」。
3670
+ */
3671
+ const SUPPLIER_CONTRACT_MEMBERS = [
3672
+ {
3673
+ key: "id",
3674
+ label: "供应商 id",
3675
+ required: true,
3676
+ probe: "safe"
3677
+ },
3678
+ {
3679
+ key: "name",
3680
+ label: "显示名",
3681
+ required: true,
3682
+ probe: "safe"
3683
+ },
3684
+ {
3685
+ key: "priority",
3686
+ label: "优先级",
3687
+ required: false,
3688
+ probe: "safe"
3689
+ },
3690
+ {
3691
+ key: "icon",
3692
+ label: "图标",
3693
+ required: false,
3694
+ probe: "safe"
3695
+ },
3696
+ {
3697
+ key: "apiKeyHint",
3698
+ label: "API key 提示文案",
3699
+ required: false,
3700
+ probe: "safe"
3701
+ },
3702
+ {
3703
+ key: "status",
3704
+ label: "账号状态",
3705
+ required: true,
3706
+ probe: "safe"
3707
+ },
3708
+ {
3709
+ key: "listModels",
3710
+ label: "模型列表",
3711
+ required: true,
3712
+ probe: "safe"
3713
+ },
3714
+ {
3715
+ key: "chatOnce",
3716
+ label: "调用上游",
3717
+ required: true,
3718
+ probe: "skip",
3719
+ skipReason: "会真的发一次请求(消耗额度)。用上面的「跑一次访问测试」单独测,或在供应商详情里测模型。"
3720
+ },
3721
+ {
3722
+ key: "checkinNow",
3723
+ label: "签到",
3724
+ required: false,
3725
+ probe: "skip",
3726
+ skipReason: "会替这个连接真的去签到。"
3727
+ }
3728
+ ];
3729
+ /** 读原始模块实例(`wrapModule` 挂在 wrapper 上)。 */
3730
+ function rawModule(loaded) {
3731
+ return loaded.supplier.__module ?? {};
3732
+ }
3733
+ /** 读出账号状态列表(只读,不改任何东西)。 */
3734
+ function accountStates(status) {
3735
+ if (typeof status !== "object" || status === null) return [];
3736
+ const accounts = status.accounts;
3737
+ if (!Array.isArray(accounts)) return [];
3738
+ return accounts.flatMap((a) => {
3739
+ const uid = a?.uid;
3740
+ if (typeof uid !== "string") return [];
3741
+ return [{
3742
+ uid,
3743
+ state: typeof a.state === "string" ? a.state : "ok",
3744
+ cooling: a.cooling === true
3745
+ }];
3746
+ });
3747
+ }
3748
+ /** 第一个连接 uid(`checkinNow` 之类要真实连接时用)。 */
3749
+ function firstAccountUid(m) {
3750
+ return accountStates(m.status())[0]?.uid;
3751
+ }
3752
+ /** 账号状态分布:`正常 2、冷却 1`、`session_dead 1` 这样。 */
3753
+ function accountTally(accounts) {
3754
+ const counts = /* @__PURE__ */ new Map();
3755
+ for (const a of accounts) {
3756
+ const key = a.cooling ? "冷却" : a.state === "ok" ? "正常" : a.state;
3757
+ counts.set(key, (counts.get(key) ?? 0) + 1);
3758
+ }
3759
+ return [...counts].map(([k, n]) => `${k} ${n}`).join("、");
3760
+ }
3761
+ /**
3762
+ * 摘要:把返回值压成一行**对排查有用**的文本。
3763
+ *
3764
+ * 原先只说「返回 number」「返回 data:image/svg+xml,%3Csvg…」——等于没说话:
3765
+ * 体检的意义就是让人一眼看出「priority 是几」「图标给了没有」。
3766
+ */
3767
+ function describe(value) {
3768
+ if (value === void 0) return "未提供(可选)";
3769
+ if (value === null) return "返回 null";
3770
+ if (Array.isArray(value)) return `${value.length} 项`;
3771
+ if (typeof value === "string") {
3772
+ if (value.startsWith("data:")) return `已提供(内联 ${value.slice(5, value.indexOf("/") || 12)})`;
3773
+ if (value === "") return "返回空串";
3774
+ return value.length > 48 ? `返回 ${value.slice(0, 48)}…` : `返回 ${value}`;
3775
+ }
3776
+ if (typeof value === "number" || typeof value === "boolean") return `返回 ${value}`;
3777
+ if (typeof value === "object") {
3778
+ const o = value;
3779
+ if (typeof o.ok === "boolean") return o.ok === false ? `ok:false ${String(o.error ?? o.message ?? "")}`.trim() : "ok:true";
3780
+ return Object.keys(o).slice(0, 4).join(", ") || "空对象";
3781
+ }
3782
+ return `返回 ${typeof value}`;
3783
+ }
3784
+ /** 异步取一个值并压成摘要(同步抛错也要接住)。 */
3785
+ async function probeValue(call) {
3786
+ try {
3787
+ return {
3788
+ ok: true,
3789
+ detail: describe(await call())
3790
+ };
3791
+ } catch (err) {
3792
+ return {
3793
+ ok: false,
3794
+ detail: err.message
3795
+ };
3796
+ }
3797
+ }
3798
+ /**
3799
+ *(这里原本有一份「永不实跑」名单:`dispose` / `addApiKey`。
3800
+ * 两者都已移出体检清单,名单随之作废 —— 留着空机制比删掉更糟:
3801
+ * 它看起来像个可配置的开关,其实没有任何成员会命中。
3802
+ * 「为什么要移」见 PROBE_EXCLUDED_MEMBERS。)
3803
+ */
3804
+ /** 深度档用来「不碰真实状态就跑通代码路径」的探针输入。 */
3805
+ const PROBE_UID = "__probe_no_such_uid__";
3806
+ const PROBE_CALLBACK = "https://invalid.example/__probe__";
3807
+ /**
3808
+ * 体检一个已装载的供应商。
3809
+ *
3810
+ * 只跑 `probe: 'safe'` 的成员;`skip` 的只报存在性。绝不代替用户调有副作用的成员。
3811
+ *
3812
+ * @param models - 该供应商当前的模型启用状态(由调用方用 `router.modelsOf` 取,
3813
+ * 走核心缓存不额外打上游)。取不到就传 undefined —— 报告里会如实写「拿不到」,
3814
+ * 不能拿 0 冒充「一个模型都没有」。
3815
+ */
3816
+ async function probeSupplier(loaded, input = {}) {
3817
+ const m = rawModule(loaded);
3818
+ const members = [];
3819
+ for (const member of SUPPLIER_CONTRACT_MEMBERS) {
3820
+ const present = member.key in m ? m[member.key] !== void 0 : false;
3821
+ const base = {
3822
+ key: member.key,
3823
+ label: member.label,
3824
+ required: member.required,
3825
+ present,
3826
+ executed: present ? "ran" : "no"
3827
+ };
3828
+ if (!present) {
3829
+ members.push({
3830
+ ...base,
3831
+ state: member.required ? "fail" : "absent",
3832
+ ...member.required ? { detail: "必填成员缺失,插件不完整" } : {}
3833
+ });
3834
+ continue;
3835
+ }
3836
+ const outcome = member.probe === "safe" ? await runSafeProbe(member.key, m, input) : await runProbedMember(member.key, m, input);
3837
+ members.push({
3838
+ ...base,
3839
+ state: outcome.ok ? "ok" : outcome.detail.startsWith("无法验证") ? "unverified" : "fail",
3840
+ detail: outcome.detail
3841
+ });
3842
+ }
3843
+ const bulkResult = await runBulkRoundTrip(input);
3844
+ input.models;
3845
+ const summary = {
3846
+ total: members.length,
3847
+ implemented: members.filter((x) => x.present).length,
3848
+ ok: members.filter((x) => x.state === "ok").length,
3849
+ fail: members.filter((x) => x.state === "fail").length,
3850
+ absent: members.filter((x) => x.state === "absent").length,
3851
+ unverified: members.filter((x) => x.state === "unverified").length
3852
+ };
3853
+ const listModels = members.find((x) => x.key === "listModels");
3854
+ return {
3855
+ supplier: loaded.supplier.id,
3856
+ name: loaded.supplier.name,
3857
+ members,
3858
+ core: await coreReport(input, listModels?.state === "ok", bulkResult),
3859
+ summary: {
3860
+ ...summary,
3861
+ ran: members.filter((x) => x.executed === "ran" && x.present).length,
3862
+ bulk: bulkResult.ran ? bulkResult.ok === true ? "ok" : "fail" : "unverified"
3863
+ }
3864
+ };
3865
+ }
3866
+ /**
3867
+ * 实跑「有副作用但可以安全试」的成员:要么用**无害探针输入**跑通代码路径,要么用
3868
+ * **连接池里的真实连接**真跑一次(前提由使用者保证:池里已放好可用 token)。
3869
+ */
3870
+ async function runProbedMember(key, m, input) {
3871
+ try {
3872
+ switch (key) {
3873
+ case "chatOnce": {
3874
+ if (input.runChatOnce === void 0) return {
3875
+ ok: false,
3876
+ detail: "无法验证:没有发起真实调用的入口"
3877
+ };
3878
+ const model = input.models?.find((x) => x.enabled)?.id ?? input.models?.[0]?.id;
3879
+ if (model === void 0) return {
3880
+ ok: false,
3881
+ detail: "无法验证:没有已启用的模型(先启用至少一个)"
3882
+ };
3883
+ const r = await input.runChatOnce(model);
3884
+ return {
3885
+ ok: r.ok,
3886
+ detail: `真实调用 ${model}:${r.ok ? "通了" : `失败(${r.detail})`}`
3887
+ };
3888
+ }
3889
+ case "checkinNow": {
3890
+ const uid = firstAccountUid(m);
3891
+ if (uid === void 0) return {
3892
+ ok: false,
3893
+ detail: "无法验证:连接池里没有可用连接(先加一个 token)"
3894
+ };
3895
+ const v = await m.checkinNow?.(uid);
3896
+ return {
3897
+ ok: v?.ok !== false,
3898
+ detail: `对连接 ${uid} 真实签到:${describe(v)}`
3899
+ };
3900
+ }
3901
+ case "generateLoginUrl": return {
3902
+ ok: true,
3903
+ detail: describe(await m.generateLoginUrl?.())
3904
+ };
3905
+ case "completeLogin": return {
3906
+ ok: true,
3907
+ detail: `无效回调实跑:${describe(await m.completeLogin?.(PROBE_CALLBACK))}(没抛错即通过)`
3908
+ };
3909
+ case "removeLink": {
3910
+ const v = await m.removeLink?.(PROBE_UID);
3911
+ return {
3912
+ ok: v !== false,
3913
+ detail: `不存在的连接实跑:${describe(v)}`
3914
+ };
3915
+ }
3916
+ default: return {
3917
+ ok: true,
3918
+ detail: "未纳入自动实跑"
3919
+ };
3920
+ }
3921
+ } catch (err) {
3922
+ return {
3923
+ ok: false,
3924
+ detail: `实跑抛错(这本身就是一条问题):${err.message}`
3925
+ };
3926
+ }
3927
+ }
3928
+ /** 核心代劳的能力区:模型启用/禁用状态 + 那几个改配置的操作(只报可用性)。 */
3929
+ /** 核心区:模型启用状态 + 「全部启用/禁用」实跑结果。 */
3930
+ async function coreReport(input, modelsProbed, bulk) {
3931
+ const models = input.models;
3932
+ const enabled = models?.filter((m) => m.enabled).length;
3933
+ const disabled = models === void 0 ? void 0 : models.length - (enabled ?? 0);
3934
+ return {
3935
+ models: {
3936
+ total: models?.length ?? null,
3937
+ enabled: enabled ?? 0,
3938
+ disabled: disabled ?? 0,
3939
+ ...models === void 0 ? { note: modelsProbed ? "拿不到模型列表" : "listModels 不可用,无法读启用状态" } : {}
3940
+ },
3941
+ operations: [{
3942
+ key: "models.bulk",
3943
+ label: "模型全部启用 / 全部禁用",
3944
+ ran: bulk.ran,
3945
+ ...bulk.ok === void 0 ? {} : { ok: bulk.ok },
3946
+ detail: bulk.detail
3947
+ }]
3948
+ };
3949
+ }
3950
+ /**
3951
+ * 实跑「全部禁用 → 看列表 → 全部启用」。
3952
+ *
3953
+ * 违规的插件会在这里露馅:它把已禁用的模型从 `listModels` 里过滤掉了,于是全部禁用
3954
+ * 之后核心**看不到任何一个模型** —— 用户点「全部启用」也就再也点不回来了
3955
+ * (路由里没有可用的模型 id 可传)。
3956
+ */
3957
+ async function runBulkRoundTrip(input) {
3958
+ if (input.runBulkToggleRoundTrip === void 0) return {
3959
+ ran: false,
3960
+ detail: "没有模型可禁用"
3961
+ };
3962
+ try {
3963
+ const r = await input.runBulkToggleRoundTrip();
3964
+ return {
3965
+ ran: true,
3966
+ ok: r.ok,
3967
+ detail: r.detail
3968
+ };
3969
+ } catch (err) {
3970
+ return {
3971
+ ran: true,
3972
+ ok: false,
3973
+ detail: `实跑抛错:${err.message}`
3974
+ };
3975
+ }
3976
+ }
3977
+ /** 无副作用成员的实跑。**每个 case 都必须没有副作用** —— 新增成员时照此办理。 */
3978
+ async function runSafeProbe(key, m, input) {
3979
+ switch (key) {
3980
+ case "id":
3981
+ case "name":
3982
+ case "priority":
3983
+ case "icon":
3984
+ case "apiKeyHint": {
3985
+ const value = m[key];
3986
+ if (value === void 0) return {
3987
+ ok: true,
3988
+ detail: "未提供(可选)"
3989
+ };
3990
+ return {
3991
+ ok: true,
3992
+ detail: describe(value)
3993
+ };
3994
+ }
3995
+ case "status": {
3996
+ const r = await probeValue(() => m.status());
3997
+ if (!r.ok) return r;
3998
+ const accounts = accountStates(m.status());
3999
+ return {
4000
+ ok: true,
4001
+ detail: accounts.length === 0 ? "无账号(无账号直连型供应商)" : `${accounts.length} 个账号:${accountTally(accounts)}`
4002
+ };
4003
+ }
4004
+ case "listModels": return await probeValue(() => m.listModels());
4005
+ case "pollLogin": return await probeValue(() => m.pollLogin?.());
4006
+ default: return {
4007
+ ok: true,
4008
+ detail: "未纳入自动探测"
4009
+ };
4010
+ }
4011
+ }
4012
+ //#endregion
3321
4013
  //#region src/suppliers/registry.ts
3322
4014
  function writeJson$1(res, status, body) {
3323
4015
  const payload = JSON.stringify(body);
@@ -3394,6 +4086,106 @@ function supplierRoutes(base, loaded, store, router) {
3394
4086
  });
3395
4087
  }
3396
4088
  });
4089
+ routes.push({
4090
+ kind: "exact",
4091
+ path: `${p}/enabled`,
4092
+ handler: async (req, res) => {
4093
+ let body;
4094
+ try {
4095
+ body = JSON.parse(await readBody$1(req));
4096
+ } catch {
4097
+ writeJson$1(res, 400, {
4098
+ ok: false,
4099
+ error: "invalid JSON body"
4100
+ });
4101
+ return;
4102
+ }
4103
+ if (typeof body.enabled !== "boolean") {
4104
+ writeJson$1(res, 400, {
4105
+ ok: false,
4106
+ error: "enabled must be a boolean"
4107
+ });
4108
+ return;
4109
+ }
4110
+ store.setEnabled(s.id, body.enabled);
4111
+ router.invalidateModels(s.id);
4112
+ writeJson$1(res, 200, {
4113
+ ok: true,
4114
+ id: s.id,
4115
+ enabled: router.isEnabled(s.id)
4116
+ });
4117
+ }
4118
+ });
4119
+ routes.push({
4120
+ kind: "exact",
4121
+ path: `${p}/probe`,
4122
+ handler: async (req, res) => {
4123
+ if (req.method !== "POST") {
4124
+ writeJson$1(res, 405, {
4125
+ ok: false,
4126
+ error: "probe requires POST"
4127
+ });
4128
+ return;
4129
+ }
4130
+ try {
4131
+ let pick = {};
4132
+ const raw = (await readBody$1(req)).trim();
4133
+ if (raw !== "") {
4134
+ const body = JSON.parse(raw);
4135
+ pick = {
4136
+ ...typeof body.model === "string" ? { model: body.model } : {},
4137
+ ...typeof body.uid === "string" ? { uid: body.uid } : {}
4138
+ };
4139
+ }
4140
+ writeJson$1(res, 200, {
4141
+ ok: true,
4142
+ report: await probeSupplier(loaded, {
4143
+ models: await router.modelsOf(s.id).catch(() => void 0),
4144
+ runBulkToggleRoundTrip: async () => {
4145
+ const original = [...store.get(s.id).disabled];
4146
+ const ids = (await router.modelsOf(s.id)).map((mm) => mm.id);
4147
+ if (ids.length === 0) return {
4148
+ ok: true,
4149
+ detail: "该供应商没有模型,跳过"
4150
+ };
4151
+ try {
4152
+ store.setAllModelsEnabled(s.id, false, ids);
4153
+ router.invalidateModels(s.id);
4154
+ const after = await router.modelsOf(s.id, true);
4155
+ const hidden = after.filter((mm) => !mm.enabled).length;
4156
+ const missing = ids.length - after.length;
4157
+ if (hidden !== ids.length || missing !== 0) return {
4158
+ ok: false,
4159
+ detail: `全部禁用后只剩 ${after.length}/${ids.length} 个 —— 插件在 listModels 里藏了已禁用的模型(启用状态由核心合并)`
4160
+ };
4161
+ return {
4162
+ ok: true,
4163
+ detail: `全部禁用后 ${after.length} 个都在,再启用可恢复`
4164
+ };
4165
+ } finally {
4166
+ store.setAllModelsEnabled(s.id, true, ids);
4167
+ for (const id of original) store.setAllModelsEnabled(s.id, false, [id]);
4168
+ router.invalidateModels(s.id);
4169
+ await router.modelsOf(s.id, true);
4170
+ }
4171
+ },
4172
+ runChatOnce: async (model) => {
4173
+ const r = await router.testModel(s.id, model, pick.uid);
4174
+ return {
4175
+ ok: r.ok,
4176
+ detail: r.ok ? "通了" : r.error ?? "上游没给原因"
4177
+ };
4178
+ }
4179
+ })
4180
+ });
4181
+ } catch (err) {
4182
+ writeJson$1(res, 500, {
4183
+ ok: false,
4184
+ error: err.message
4185
+ });
4186
+ }
4187
+ }
4188
+ });
3397
4189
  routes.push({
3398
4190
  kind: "exact",
3399
4191
  path: `${p}/alias`,
@@ -3512,29 +4304,6 @@ function supplierRoutes(base, loaded, store, router) {
3512
4304
  writeJson$1(res, 200, { ok: true });
3513
4305
  }
3514
4306
  });
3515
- routes.push({
3516
- kind: "exact",
3517
- path: `${p}/pool/strategy`,
3518
- handler: async (req, res) => {
3519
- if (req.method === "GET") {
3520
- writeJson$1(res, 200, {
3521
- ok: true,
3522
- strategy: store.get(s.id).poolStrategy
3523
- });
3524
- return;
3525
- }
3526
- const body = JSON.parse(await readBody$1(req));
3527
- if (body.strategy !== "fallback" && body.strategy !== "round-robin") {
3528
- writeJson$1(res, 400, {
3529
- ok: false,
3530
- error: "策略无效"
3531
- });
3532
- return;
3533
- }
3534
- store.setPoolStrategy(s.id, body.strategy);
3535
- writeJson$1(res, 200, { ok: true });
3536
- }
3537
- });
3538
4307
  routes.push({
3539
4308
  kind: "exact",
3540
4309
  path: `${p}/models/fetch`,
@@ -3550,7 +4319,8 @@ function supplierRoutes(base, loaded, store, router) {
3550
4319
  path: `${p}/models/test`,
3551
4320
  handler: async (req, res) => {
3552
4321
  const body = JSON.parse(await readBody$1(req));
3553
- const result = await router.testModel(s.id, body.id ?? "");
4322
+ const onlyUid = typeof body.uid === "string" && body.uid !== "" ? body.uid : void 0;
4323
+ const result = await router.testModel(s.id, body.id ?? "", onlyUid);
3554
4324
  writeJson$1(res, result.ok ? 200 : 400, result);
3555
4325
  }
3556
4326
  });
@@ -3795,6 +4565,98 @@ function dshHome() {
3795
4565
  return resolve(value);
3796
4566
  }
3797
4567
  //#endregion
4568
+ //#region src/host-version.ts
4569
+ /**
4570
+ * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
4571
+ *
4572
+ * 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
4573
+ * ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
4574
+ * 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
4575
+ * profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
4576
+ *
4577
+ * 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
4578
+ * 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
4579
+ * Config 驱动(无此方法)→ 按版本走对应注册路径。
4580
+ * 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
4581
+ * 块;0.1.7+ = 独立 role:'tool' 消息。
4582
+ *
4583
+ * 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
4584
+ * 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
4585
+ * 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
4586
+ * 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
4587
+ *
4588
+ * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
4589
+ * 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
4590
+ */
4591
+ /** 各兼容分叉的版本门槛。 */
4592
+ /** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
4593
+ const V0_1_7 = [
4594
+ 0,
4595
+ 1,
4596
+ 7
4597
+ ];
4598
+ /**
4599
+ * 解析并比较 semver(只取前导数字段,预发布标识忽略)。
4600
+ * `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
4601
+ * 返回负数/0/正数,语义同 `<`/`==`/`>`。
4602
+ */
4603
+ function compareVersion(a, b) {
4604
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
4605
+ const x = a[i] ?? 0;
4606
+ const y = b[i] ?? 0;
4607
+ if (x !== y) return x - y;
4608
+ }
4609
+ return 0;
4610
+ }
4611
+ /** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
4612
+ function parseVersion(raw) {
4613
+ const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim());
4614
+ if (m === null) return null;
4615
+ return [
4616
+ Number(m[1]),
4617
+ Number(m[2] ?? 0),
4618
+ Number(m[3] ?? 0)
4619
+ ];
4620
+ }
4621
+ /**
4622
+ * 探测 profile 里安装的 DSH 宿主版本。
4623
+ * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
4624
+ * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
4625
+ */
4626
+ function detectHostVersion(baseUrl) {
4627
+ const profile = profileDirOf(baseUrl);
4628
+ const candidates = [];
4629
+ try {
4630
+ candidates.push(createRequire(join(profile, "noop.js")).resolve("@deepseek-ai/dsh/package.json"));
4631
+ } catch {}
4632
+ candidates.push(join(profile, "node_modules", "@deepseek-ai", "dsh", "package.json"));
4633
+ for (const file of candidates) try {
4634
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
4635
+ if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
4636
+ } catch {}
4637
+ }
4638
+ /** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
4639
+ function atLeast(baseUrl, min) {
4640
+ const v = detectHostVersion(baseUrl);
4641
+ if (v === void 0) return false;
4642
+ const parsed = parseVersion(v);
4643
+ if (parsed === null) return false;
4644
+ return compareVersion(parsed, min) >= 0;
4645
+ }
4646
+ /**
4647
+ * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
4648
+ * - 「最近命中」徽章(composer.dock 位置正确)
4649
+ * - 设置命名空间(Config 驱动而非 installSection)
4650
+ * - adapter 的独立 role:'tool' 消息形态
4651
+ */
4652
+ function isHost017Plus(baseUrl) {
4653
+ return atLeast(baseUrl, V0_1_7);
4654
+ }
4655
+ /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
4656
+ function supportsLastHitDock(baseUrl) {
4657
+ return isHost017Plus(baseUrl);
4658
+ }
4659
+ //#endregion
3798
4660
  //#region src/ext/store.ts
3799
4661
  /**
3800
4662
  * 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
@@ -3913,6 +4775,31 @@ var ExtStore = class {
3913
4775
  const name = "dsh-router-core";
3914
4776
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
3915
4777
  const inject = ["webServer", "llm"];
4778
+ /**
4779
+ * `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
4780
+ *
4781
+ * **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
4782
+ * adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
4783
+ * 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
4784
+ * 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
4785
+ * (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
4786
+ * 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
4787
+ * 能力检测恰好就是**对**的判据。
4788
+ *
4789
+ * 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
4790
+ * 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
4791
+ * 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
4792
+ * - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
4793
+ * - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
4794
+ * installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
4795
+ */
4796
+ const emptySchema = Schema.object({});
4797
+ /**
4798
+ * 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
4799
+ * 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
4800
+ * Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
4801
+ */
4802
+ const Config = typeof emptySchema.volatile === "function" ? emptySchema.volatile() : emptySchema;
3916
4803
  function writeJson(res, status, body) {
3917
4804
  const payload = JSON.stringify(body);
3918
4805
  res.writeHead(status, {
@@ -3945,6 +4832,10 @@ function apply(rawContext) {
3945
4832
  const dataDir = dataDirOf(ctx.baseUrl);
3946
4833
  const stateFile = join(dataDir, "state.json");
3947
4834
  log(`data dir: ${dataDir}`);
4835
+ const hostVersion = detectHostVersion(ctx.baseUrl);
4836
+ const host017Plus = isHost017Plus(ctx.baseUrl);
4837
+ const lastHitDock = supportsLastHitDock(ctx.baseUrl);
4838
+ log(`host version: ${hostVersion ?? "未知"} (>=0.1.7: ${host017Plus ? "yes" : "no"}, last-hit dock: ${lastHitDock ? "on" : "off"})`);
3948
4839
  const store = new SupplierConfigStore(stateFile);
3949
4840
  const credentials = new CredentialStore(join(dataDir, "auths"));
3950
4841
  const router = new Router(stateFile, store, log);
@@ -3983,6 +4874,15 @@ function apply(rawContext) {
3983
4874
  const extStore = new ExtStore(stateFile);
3984
4875
  ctx.provide("router.extStore", extStore);
3985
4876
  const registerLoaded = (loaded) => {
4877
+ const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id);
4878
+ if (existing !== void 0) {
4879
+ if (!supplierWins(loaded.source, existing.source)) {
4880
+ log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`);
4881
+ return;
4882
+ }
4883
+ log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`);
4884
+ unregisterSupplier(loaded.supplier.id);
4885
+ }
3986
4886
  loadedSuppliers.push(loaded);
3987
4887
  router.add(loaded.supplier);
3988
4888
  registerSupplierRoutes(loaded);
@@ -3993,22 +4893,24 @@ function apply(rawContext) {
3993
4893
  for (const [sid, factory] of Object.entries(suppliers)) {
3994
4894
  if (externalSupplierIds.includes(sid)) continue;
3995
4895
  try {
3996
- const loaded = wrapModule(factory({
4896
+ const module = factory({
3997
4897
  dataDir,
3998
4898
  log,
3999
4899
  store,
4000
4900
  credentials
4001
- }), {
4901
+ });
4902
+ const kind = factory.source === "builtin" ? "builtin" : "external";
4903
+ const loaded = wrapModule(module, {
4002
4904
  dataDir,
4003
4905
  log,
4004
4906
  store,
4005
4907
  credentials
4006
- }, `service router.suppliers.${sid}`);
4908
+ }, `service router.suppliers.${sid}`, kind);
4007
4909
  registerLoaded(loaded);
4008
4910
  externalSupplierIds.push(loaded.supplier.id);
4009
- log(`external supplier loaded: ${sid}`);
4911
+ log(`${kind} supplier loaded: ${sid}`);
4010
4912
  } catch (err) {
4011
- ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${err.message}`);
4913
+ ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${err.message}`);
4012
4914
  }
4013
4915
  }
4014
4916
  };
@@ -4027,10 +4929,8 @@ function apply(rawContext) {
4027
4929
  });
4028
4930
  (async () => {
4029
4931
  try {
4030
- const userDir = join(profileDirOf(ctx.baseUrl), "suppliers");
4031
4932
  const { suppliers, errors } = await loadSuppliers({
4032
- builtinDir: join(import.meta.dirname, "suppliers"),
4033
- userDir,
4933
+ userDir: join(profileDirOf(ctx.baseUrl), "suppliers"),
4034
4934
  dataDir,
4035
4935
  store,
4036
4936
  credentials,
@@ -4088,17 +4988,22 @@ function apply(rawContext) {
4088
4988
  return;
4089
4989
  }
4090
4990
  const lv = typeof peek.reasoning_effort === "string" && peek.reasoning_effort !== "" ? peek.reasoning_effort : "auto";
4991
+ const sessionRaw = req.headers["x-dsh-router-session"];
4992
+ const session = typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0;
4091
4993
  await router.chatCompletions({
4092
4994
  rawBody: body,
4093
4995
  stream: !!peek.stream,
4094
4996
  model: typeof peek.model === "string" ? peek.model : "",
4095
- lv
4997
+ lv,
4998
+ ...session === void 0 ? {} : { session }
4096
4999
  }, res);
4097
5000
  }));
4098
5001
  route(`${ROUTER_API_BASE}/health`, (_req, res) => {
4099
5002
  const { suppliers } = router.status();
4100
5003
  writeJson(res, 200, {
4101
5004
  ok: true,
5005
+ hostVersion: hostVersion ?? "",
5006
+ lastHitDock,
4102
5007
  suppliers: suppliers.map((s) => {
4103
5008
  const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
4104
5009
  return {
@@ -4107,7 +5012,8 @@ function apply(rawContext) {
4107
5012
  icon: loaded?.supplier.icon,
4108
5013
  apiKeyHint: loaded?.supplier.apiKeyHint,
4109
5014
  capabilities: loaded ? [...loaded.capabilities] : [],
4110
- source: loaded?.source ?? "external"
5015
+ source: loaded?.source ?? "external",
5016
+ enabled: s.enabled
4111
5017
  };
4112
5018
  })
4113
5019
  });
@@ -4143,6 +5049,33 @@ function apply(rawContext) {
4143
5049
  store.setAccountName(body.supplier, body.uid, typeof body.name === "string" ? body.name : "");
4144
5050
  writeJson(res, 200, { ok: true });
4145
5051
  });
5052
+ route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
5053
+ const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? void 0;
5054
+ const last = router.usage.lastHit(session);
5055
+ if (last === void 0) {
5056
+ writeJson(res, 200, {
5057
+ ok: true,
5058
+ hit: null
5059
+ });
5060
+ return;
5061
+ }
5062
+ const { suppliers } = router.status();
5063
+ const acct = suppliers.find((s) => s.id === last.supplier)?.accounts.find((a) => a.uid === last.uid);
5064
+ const named = last.uid === void 0 || last.uid === "" ? void 0 : store.getAccountName(last.supplier, last.uid);
5065
+ writeJson(res, 200, {
5066
+ ok: true,
5067
+ hit: {
5068
+ supplier: last.supplier,
5069
+ model: last.model,
5070
+ requested: last.requested,
5071
+ uid: last.uid,
5072
+ account: named ?? acct?.nickname ?? (last.uid === void 0 || last.uid === "" ? void 0 : last.uid),
5073
+ credits: acct?.credits,
5074
+ ok: last.ok,
5075
+ ts: last.ts
5076
+ }
5077
+ });
5078
+ });
4146
5079
  route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
4147
5080
  try {
4148
5081
  writeJson(res, 200, {
@@ -4176,7 +5109,7 @@ function apply(rawContext) {
4176
5109
  });
4177
5110
  return;
4178
5111
  }
4179
- const result = router.createCombo(body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
5112
+ const result = router.createCombo(body.name ?? "", body.models ?? []);
4180
5113
  writeJson(res, result.ok ? 200 : 400, result);
4181
5114
  });
4182
5115
  route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
@@ -4190,7 +5123,7 @@ function apply(rawContext) {
4190
5123
  });
4191
5124
  return;
4192
5125
  }
4193
- const result = router.updateCombo(body.id ?? "", body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
5126
+ const result = router.updateCombo(body.id ?? "", body.name ?? "", body.models ?? []);
4194
5127
  writeJson(res, result.ok ? 200 : 400, result);
4195
5128
  });
4196
5129
  route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
@@ -4336,37 +5269,12 @@ function apply(rawContext) {
4336
5269
  ...e.icon !== void 0 ? { icon: e.icon } : {},
4337
5270
  enabled: extStore.isEnabled(e.id),
4338
5271
  ready: st?.ready === true,
5272
+ ...raw.source === "builtin" ? { source: "builtin" } : {},
4339
5273
  ...st?.detail !== void 0 ? { detail: st.detail } : {}
4340
5274
  };
4341
5275
  }).filter((e) => e !== void 0)
4342
5276
  });
4343
5277
  });
4344
- const extDispatch = ctx.webServer.register({
4345
- kind: "prefix",
4346
- path: `${ROUTER_API_BASE}/ext/`,
4347
- handler: async (req, res) => {
4348
- const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4349
- const mount = `${ROUTER_API_BASE}/ext/`;
4350
- const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
4351
- const slash = rest.indexOf("/");
4352
- const extId = slash < 0 ? rest : rest.slice(0, slash);
4353
- const subpath = slash < 0 ? "" : rest.slice(slash + 1);
4354
- if (extId === "") {
4355
- writeJson(res, 404, {
4356
- ok: false,
4357
- error: "extension not found"
4358
- });
4359
- return;
4360
- }
4361
- const ext = exts[extId];
4362
- if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
4363
- writeJson(res, 404, {
4364
- ok: false,
4365
- error: "extension api not found"
4366
- });
4367
- }
4368
- });
4369
- disposers.push(extDispatch);
4370
5278
  /** 解析并校验周期参数(不接受任意字符串)。 */
4371
5279
  const periodOf = (raw) => {
4372
5280
  return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
@@ -4391,7 +5299,10 @@ function apply(rawContext) {
4391
5299
  bySupplier: s.bySupplier,
4392
5300
  byModel: s.byModel,
4393
5301
  byRequested: s.byRequested,
4394
- recent: router.usage.recentList(20)
5302
+ recent: router.usage.recentList(20).map((r) => ({
5303
+ ...r,
5304
+ connection: r.uid === void 0 || r.uid === "" ? "" : store.getAccountName(r.supplier, r.uid) ?? r.uid
5305
+ }))
4395
5306
  });
4396
5307
  });
4397
5308
  route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
@@ -4412,53 +5323,57 @@ function apply(rawContext) {
4412
5323
  router.usage.clear();
4413
5324
  writeJson(res, 200, { ok: true });
4414
5325
  });
4415
- if (ctx.llm !== void 0) {
4416
- const LLM_NS = "llm-dsh-router";
4417
- try {
4418
- const providerReg = ctx.llm.registerConfigurableProviders([{
4419
- provider: "router",
4420
- displayName: "Router",
4421
- settingsNs: LLM_NS,
4422
- settingsPath: []
4423
- }]);
4424
- disposers.push(providerReg);
4425
- disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
4426
- return (await router.combos()).map((c) => ({ id: c.name }));
4427
- }));
4428
- const routerSettingsSchema = Schema.object({});
4429
- ctx.inject(["settings"], (sctx) => {
4430
- const settings = sctx.settings;
4431
- if (settings === void 0) {
4432
- log("settings service absent — skip Router settings-section registration");
4433
- return;
4434
- }
4435
- settings.installSection(rawContext, LLM_NS, routerSettingsSchema, {}, {
4436
- setSource: () => {},
4437
- onChange: () => {}
4438
- });
4439
- log(`Router settings section registered (${LLM_NS})`);
5326
+ const settingsNs = ctx.fiber?.entry?.options?.id ?? "dsh-router";
5327
+ if (ctx.llm !== void 0) try {
5328
+ const providerReg = ctx.llm.registerConfigurableProviders([{
5329
+ provider: "router",
5330
+ displayName: "Router",
5331
+ settingsNs,
5332
+ settingsPath: []
5333
+ }]);
5334
+ disposers.push(providerReg);
5335
+ disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
5336
+ return (await router.combos()).map((c) => ({ id: c.name }));
5337
+ }));
5338
+ const routerSettingsSchema = Schema.object({});
5339
+ if (isHost017Plus(ctx.baseUrl)) log(`settings section driven by exported Config (${settingsNs})`);
5340
+ else ctx.inject(["settings"], (sctx) => {
5341
+ const settings = sctx.settings;
5342
+ if (settings === void 0) {
5343
+ log("settings service absent — skip Router settings-section registration");
5344
+ return;
5345
+ }
5346
+ if (settings.installSection === void 0) {
5347
+ log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`);
5348
+ return;
5349
+ }
5350
+ settings.installSection(rawContext, settingsNs, routerSettingsSchema, {}, {
5351
+ setSource: () => {},
5352
+ onChange: () => {}
4440
5353
  });
4441
- disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
4442
- const w = router.comboContextWindow(c);
4443
- return {
4444
- id: c.name,
4445
- ...w !== void 0 ? { contextWindow: w } : {}
4446
- };
4447
- }) }, () => ctx.get("attachments"))));
4448
- log("llm provider (Router) + discovery + adapter registered ok");
4449
- (async () => {
4450
- for (const c of await router.combos()) for (const ref of c.models) {
4451
- const at = ref.indexOf(",");
4452
- if (at < 0) continue;
4453
- try {
4454
- await router.modelsOf(ref.slice(0, at));
4455
- } catch {}
4456
- }
4457
- })().catch(() => {});
4458
- } catch (err) {
4459
- ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
4460
- }
4461
- } else log("llm service NOT available — skip 设置-模型 registration");
5354
+ log(`Router settings section registered (${settingsNs})`);
5355
+ });
5356
+ disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
5357
+ const w = router.comboContextWindow(c);
5358
+ return {
5359
+ id: c.name,
5360
+ ...w !== void 0 ? { contextWindow: w } : {}
5361
+ };
5362
+ }) }, () => ctx.get("attachments"), void 0, (msg) => ctx.logger.warn(`[dsh-router] ${msg}`))));
5363
+ log("llm provider (Router) + discovery + adapter registered ok");
5364
+ (async () => {
5365
+ for (const c of await router.combos()) for (const ref of c.models) {
5366
+ const at = ref.indexOf(",");
5367
+ if (at < 0) continue;
5368
+ try {
5369
+ await router.modelsOf(ref.slice(0, at));
5370
+ } catch {}
5371
+ }
5372
+ })().catch(() => {});
5373
+ } catch (err) {
5374
+ ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
5375
+ }
5376
+ else log("llm service NOT available — skip 设置-模型 registration");
4462
5377
  route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
4463
5378
  let directory = "n/a";
4464
5379
  let registeredProviders = "n/a";
@@ -4485,7 +5400,7 @@ function apply(rawContext) {
4485
5400
  directory,
4486
5401
  registeredProviders,
4487
5402
  settingsNamespaces,
4488
- routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
5403
+ routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs)
4489
5404
  });
4490
5405
  });
4491
5406
  ctx.effect(() => () => {
@@ -4497,4 +5412,4 @@ function apply(rawContext) {
4497
5412
  }, "dsh-router: teardown");
4498
5413
  }
4499
5414
  //#endregion
4500
- export { apply, inject, name };
5415
+ export { Config, apply, inject, name };