dsh-router-core 0.3.21 → 0.4.0

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 (49) hide show
  1. package/README.md +77 -34
  2. package/docs/pool-sticky-block.md +158 -0
  3. package/lib/client-registry.js +351 -407
  4. package/lib/client.js +351 -407
  5. package/lib/index.js +383 -170
  6. package/lib/types/ext/contract.d.ts +0 -7
  7. package/lib/types/host-version.d.ts +15 -0
  8. package/lib/types/index.d.ts +6 -0
  9. package/lib/types/router/account-pool.d.ts +53 -18
  10. package/lib/types/router/index.d.ts +4 -7
  11. package/lib/types/router/prefix-affinity.d.ts +18 -1
  12. package/lib/types/router/types.d.ts +7 -4
  13. package/lib/types/router/usage-store.d.ts +12 -0
  14. package/lib/types/shared.d.ts +13 -21
  15. package/lib/types/supplier-config.d.ts +0 -3
  16. package/lib/types/suppliers/contract.d.ts +2 -2
  17. package/lib/types/suppliers/registry.d.ts +0 -1
  18. package/package.json +12 -13
  19. package/src/client/CombosTab.tsx +8 -53
  20. package/src/client/ExtDetail.tsx +15 -178
  21. package/src/client/LastHitDock.tsx +141 -0
  22. package/src/client/StatsTab.tsx +8 -6
  23. package/src/client/SupplierDetail.tsx +2 -34
  24. package/src/client/index.tsx +45 -0
  25. package/src/client/last-hit-store.ts +89 -0
  26. package/src/client/model-hint-copy.test.ts +7 -2
  27. package/src/client/model-hint-copy.ts +2 -2
  28. package/src/client/router.css +2106 -1630
  29. package/src/ext/contract.ts +0 -7
  30. package/src/host-version.test.ts +61 -0
  31. package/src/host-version.ts +101 -0
  32. package/src/index.ts +133 -59
  33. package/src/llm/adapter.test.ts +74 -0
  34. package/src/llm/adapter.ts +33 -0
  35. package/src/router/account-pool.test.ts +189 -83
  36. package/src/router/account-pool.ts +120 -31
  37. package/src/router/index.ts +22 -28
  38. package/src/router/models-cache.test.ts +9 -9
  39. package/src/router/precise-dispatch.test.ts +6 -6
  40. package/src/router/prefix-affinity.ts +22 -1
  41. package/src/router/types.ts +7 -5
  42. package/src/router/usage-store.ts +16 -0
  43. package/src/router/usage.test.ts +1 -1
  44. package/src/shared.ts +13 -24
  45. package/src/supplier-config.ts +2 -13
  46. package/src/suppliers/contract.ts +2 -2
  47. package/src/suppliers/loader.test.ts +2 -2
  48. package/src/suppliers/registry.ts +1 -20
  49. 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
@@ -471,7 +565,6 @@ var SupplierConfigStore = class {
471
565
  disabled: [],
472
566
  custom: [],
473
567
  poolOrder: [],
474
- poolStrategy: "fallback",
475
568
  credits: {},
476
569
  accountNames: {}
477
570
  };
@@ -520,11 +613,6 @@ var SupplierConfigStore = class {
520
613
  this.get(supplierId).poolOrder = [...new Set(uids)];
521
614
  this.saveLocked();
522
615
  }
523
- setPoolStrategy(supplierId, strategy) {
524
- if (strategy !== "fallback" && strategy !== "round-robin") return;
525
- this.get(supplierId).poolStrategy = strategy;
526
- this.saveLocked();
527
- }
528
616
  /** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
529
617
  getAccountName(supplierId, uid) {
530
618
  const n = this.get(supplierId).accountNames[uid];
@@ -582,7 +670,6 @@ var SupplierConfigStore = class {
582
670
  disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
583
671
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
584
672
  poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
585
- poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
586
673
  credits: readCredits(c.credits),
587
674
  accountNames: readNames(c.accountNames)
588
675
  });
@@ -595,7 +682,6 @@ var SupplierConfigStore = class {
595
682
  disabled: [...cfg.disabled],
596
683
  custom: [...cfg.custom],
597
684
  poolOrder: [...cfg.poolOrder],
598
- poolStrategy: cfg.poolStrategy,
599
685
  credits: { ...cfg.credits },
600
686
  accountNames: { ...cfg.accountNames }
601
687
  };
@@ -889,6 +975,16 @@ var UsageStore = class UsageStore {
889
975
  recentList(limit = 20) {
890
976
  return this.recent.slice(0, limit);
891
977
  }
978
+ /**
979
+ * 最近一次命中。给了 `session` 就只在该会话的记录里找(「最近命中」徽章
980
+ * 要显示**当前会话**的命中,team 多会话下全局最近会张冠李戴);
981
+ * 不给则退回全局最近一条。
982
+ * @param session 宿主会话身份;undefined = 全局
983
+ */
984
+ lastHit(session) {
985
+ if (session === void 0) return this.recent[0];
986
+ return this.recent.find((r) => r.session === session);
987
+ }
892
988
  clear() {
893
989
  this.days.clear();
894
990
  this.recent = [];
@@ -1302,8 +1398,8 @@ function withEstimates(usage, requestChars, outputChars) {
1302
1398
  //#region src/router/index.ts
1303
1399
  /**
1304
1400
  * dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
1305
- * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
1306
- * (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
1401
+ * 仿 9router:组合 = 一组模型,请求 model 命中组合名时,作为降级链
1402
+ * 按序尝试选中一个模型路由(无策略旋钮,见 docs/pool-sticky-block.md §10)。
1307
1403
  */
1308
1404
  /**
1309
1405
  * 模型列表缓存有效期。
@@ -1753,9 +1849,7 @@ var Router = class {
1753
1849
  suppliers = [];
1754
1850
  combosFp = "";
1755
1851
  customCombos = [];
1756
- /** round-robin 轮转游标(按组合 id 记忆)。 */
1757
- rrCursors = /* @__PURE__ */ new Map();
1758
- /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
1852
+ /** 通用供应商配置(连接池顺序、模型启用、别名)。 */
1759
1853
  store;
1760
1854
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
1761
1855
  modelsCache = /* @__PURE__ */ new Map();
@@ -1801,7 +1895,6 @@ var Router = class {
1801
1895
  return {
1802
1896
  id: c.id,
1803
1897
  name: typeof c.name === "string" ? c.name : c.id,
1804
- strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
1805
1898
  models
1806
1899
  };
1807
1900
  }).filter((c) => c.models.length > 0);
@@ -1998,9 +2091,6 @@ var Router = class {
1998
2091
  validModels(models) {
1999
2092
  return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
2000
2093
  }
2001
- validStrategy(strategy) {
2002
- return strategy === "fallback" || strategy === "round-robin";
2003
- }
2004
2094
  /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
2005
2095
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
2006
2096
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
@@ -2021,8 +2111,8 @@ var Router = class {
2021
2111
  return s === void 0 ? modelId : `${s.id},${modelId}`;
2022
2112
  });
2023
2113
  }
2024
- /** 创建组合(name 唯一,非 default)。 */
2025
- createCombo(name, strategy, models) {
2114
+ /** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
2115
+ createCombo(name, models) {
2026
2116
  const clean = name.trim();
2027
2117
  if (clean === "" || clean === "default") return {
2028
2118
  ok: false,
@@ -2040,14 +2130,9 @@ var Router = class {
2040
2130
  ok: false,
2041
2131
  error: "至少需要一个模型"
2042
2132
  };
2043
- if (!this.validStrategy(strategy)) return {
2044
- ok: false,
2045
- error: "策略无效"
2046
- };
2047
2133
  const combo = {
2048
2134
  id: clean,
2049
2135
  name: clean,
2050
- strategy,
2051
2136
  models: this.normalizeModelIds(models)
2052
2137
  };
2053
2138
  this.customCombos.push(combo);
@@ -2058,7 +2143,7 @@ var Router = class {
2058
2143
  };
2059
2144
  }
2060
2145
  /** 更新组合(按 id)。 */
2061
- updateCombo(id, name, strategy, models) {
2146
+ updateCombo(id, name, models) {
2062
2147
  const target = this.customCombos.find((c) => c.id === id);
2063
2148
  if (!target) return {
2064
2149
  ok: false,
@@ -2081,12 +2166,7 @@ var Router = class {
2081
2166
  ok: false,
2082
2167
  error: "至少需要一个模型"
2083
2168
  };
2084
- if (!this.validStrategy(strategy)) return {
2085
- ok: false,
2086
- error: "策略无效"
2087
- };
2088
2169
  target.name = clean;
2089
- target.strategy = strategy;
2090
2170
  target.models = this.normalizeModelIds(models);
2091
2171
  this.saveCombos();
2092
2172
  return { ok: true };
@@ -2143,15 +2223,14 @@ var Router = class {
2143
2223
  durationMs: Date.now() - probe.startedAt,
2144
2224
  ttfbMs: probe.ttfbMs,
2145
2225
  ...ok && probe.uid !== "" ? { uid: probe.uid } : {},
2226
+ ...req.session === void 0 ? {} : { session: req.session },
2146
2227
  ...error === void 0 ? {} : { error }
2147
2228
  }, usage);
2148
2229
  };
2149
2230
  const combo = this.comboByName(req.model);
2150
2231
  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
2232
  for (let i = 0; i < combo.models.length; i++) {
2154
- const model = combo.models[(start + i) % combo.models.length];
2233
+ const model = combo.models[i];
2155
2234
  if (model === void 0) continue;
2156
2235
  const trace = { attempts: 0 };
2157
2236
  if (await this.chatWithModel(req, res, model, probe, trace)) {
@@ -2234,7 +2313,7 @@ var Router = class {
2234
2313
  outTrace.lastError = trace.lastError;
2235
2314
  outTrace.lastState = trace.lastState;
2236
2315
  }
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);
2316
+ 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
2317
  return served;
2239
2318
  }
2240
2319
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
@@ -2271,9 +2350,11 @@ var Router = class {
2271
2350
  }
2272
2351
  const messages = reqMessages(req.rawBody);
2273
2352
  const tried = /* @__PURE__ */ new Set();
2353
+ let pickWhy = "";
2274
2354
  for (;;) {
2275
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages);
2355
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
2276
2356
  if (uid === void 0) return false;
2357
+ pickWhy = pool.lastWhy;
2277
2358
  tried.add(uid);
2278
2359
  const r = await s.chatOnce(uid, req.lv ?? "auto", req);
2279
2360
  if (!r.ok) {
@@ -2294,14 +2375,17 @@ var Router = class {
2294
2375
  continue;
2295
2376
  }
2296
2377
  pool.noteSuccess(uid, req.model);
2297
- if (trace !== void 0) trace.uid = uid;
2378
+ if (trace !== void 0) {
2379
+ trace.uid = uid;
2380
+ trace.pickWhy = pickWhy;
2381
+ }
2298
2382
  if (probe !== void 0) {
2299
2383
  probe.supplier = s.id;
2300
2384
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2301
2385
  probe.uid = uid;
2302
2386
  }
2303
2387
  if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
2304
- pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
2388
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
2305
2389
  return true;
2306
2390
  }
2307
2391
  pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
@@ -2390,6 +2474,10 @@ var Router = class {
2390
2474
  * 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
2391
2475
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
2392
2476
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
2477
+ *
2478
+ * 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
2479
+ * (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
2480
+ * 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
2393
2481
  */
2394
2482
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2395
2483
  const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
@@ -2461,6 +2549,19 @@ async function wireMessages(options, system, attachments) {
2461
2549
  });
2462
2550
  continue;
2463
2551
  }
2552
+ if (message.role === "tool") {
2553
+ const toolCallId = message.toolCallId;
2554
+ const parts = await contentParts(message.content, attachments, options.signal);
2555
+ const imageParts = parts.filter((p) => p.type !== "text");
2556
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2557
+ out.push({
2558
+ role: "tool",
2559
+ tool_call_id: String(toolCallId ?? ""),
2560
+ content: text
2561
+ });
2562
+ pendingToolImages.push(...imageParts);
2563
+ continue;
2564
+ }
2464
2565
  const toolResults = message.content.filter((b) => b.type === "tool-result");
2465
2566
  const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
2466
2567
  if (ownParts.length > 0 || toolResults.length === 0) {
@@ -2913,7 +3014,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2913
3014
  headers: {
2914
3015
  ...attributionHeaders(),
2915
3016
  "content-type": "application/json",
2916
- accept: "text/event-stream"
3017
+ accept: "text/event-stream",
3018
+ ...options.sessionId === void 0 ? {} : { "x-dsh-router-session": String(options.sessionId) }
2917
3019
  },
2918
3020
  body: JSON.stringify(body),
2919
3021
  signal: controller.signal
@@ -3512,29 +3614,6 @@ function supplierRoutes(base, loaded, store, router) {
3512
3614
  writeJson$1(res, 200, { ok: true });
3513
3615
  }
3514
3616
  });
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
3617
  routes.push({
3539
3618
  kind: "exact",
3540
3619
  path: `${p}/models/fetch`,
@@ -3795,6 +3874,98 @@ function dshHome() {
3795
3874
  return resolve(value);
3796
3875
  }
3797
3876
  //#endregion
3877
+ //#region src/host-version.ts
3878
+ /**
3879
+ * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
3880
+ *
3881
+ * 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
3882
+ * ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
3883
+ * 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
3884
+ * profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
3885
+ *
3886
+ * 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
3887
+ * 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
3888
+ * Config 驱动(无此方法)→ 按版本走对应注册路径。
3889
+ * 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
3890
+ * 块;0.1.7+ = 独立 role:'tool' 消息。
3891
+ *
3892
+ * 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
3893
+ * 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
3894
+ * 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
3895
+ * 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
3896
+ *
3897
+ * 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
3898
+ * 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
3899
+ */
3900
+ /** 各兼容分叉的版本门槛。 */
3901
+ /** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
3902
+ const V0_1_7 = [
3903
+ 0,
3904
+ 1,
3905
+ 7
3906
+ ];
3907
+ /**
3908
+ * 解析并比较 semver(只取前导数字段,预发布标识忽略)。
3909
+ * `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
3910
+ * 返回负数/0/正数,语义同 `<`/`==`/`>`。
3911
+ */
3912
+ function compareVersion(a, b) {
3913
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
3914
+ const x = a[i] ?? 0;
3915
+ const y = b[i] ?? 0;
3916
+ if (x !== y) return x - y;
3917
+ }
3918
+ return 0;
3919
+ }
3920
+ /** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
3921
+ function parseVersion(raw) {
3922
+ const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim());
3923
+ if (m === null) return null;
3924
+ return [
3925
+ Number(m[1]),
3926
+ Number(m[2] ?? 0),
3927
+ Number(m[3] ?? 0)
3928
+ ];
3929
+ }
3930
+ /**
3931
+ * 探测 profile 里安装的 DSH 宿主版本。
3932
+ * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
3933
+ * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
3934
+ */
3935
+ function detectHostVersion(baseUrl) {
3936
+ const profile = profileDirOf(baseUrl);
3937
+ const candidates = [];
3938
+ try {
3939
+ candidates.push(createRequire(join(profile, "noop.js")).resolve("@deepseek-ai/dsh/package.json"));
3940
+ } catch {}
3941
+ candidates.push(join(profile, "node_modules", "@deepseek-ai", "dsh", "package.json"));
3942
+ for (const file of candidates) try {
3943
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
3944
+ if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
3945
+ } catch {}
3946
+ }
3947
+ /** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
3948
+ function atLeast(baseUrl, min) {
3949
+ const v = detectHostVersion(baseUrl);
3950
+ if (v === void 0) return false;
3951
+ const parsed = parseVersion(v);
3952
+ if (parsed === null) return false;
3953
+ return compareVersion(parsed, min) >= 0;
3954
+ }
3955
+ /**
3956
+ * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
3957
+ * - 「最近命中」徽章(composer.dock 位置正确)
3958
+ * - 设置命名空间(Config 驱动而非 installSection)
3959
+ * - adapter 的独立 role:'tool' 消息形态
3960
+ */
3961
+ function isHost017Plus(baseUrl) {
3962
+ return atLeast(baseUrl, V0_1_7);
3963
+ }
3964
+ /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
3965
+ function supportsLastHitDock(baseUrl) {
3966
+ return isHost017Plus(baseUrl);
3967
+ }
3968
+ //#endregion
3798
3969
  //#region src/ext/store.ts
3799
3970
  /**
3800
3971
  * 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
@@ -3913,6 +4084,31 @@ var ExtStore = class {
3913
4084
  const name = "dsh-router-core";
3914
4085
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
3915
4086
  const inject = ["webServer", "llm"];
4087
+ /**
4088
+ * `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
4089
+ *
4090
+ * **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
4091
+ * adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
4092
+ * 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
4093
+ * 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
4094
+ * (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
4095
+ * 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
4096
+ * 能力检测恰好就是**对**的判据。
4097
+ *
4098
+ * 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
4099
+ * 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
4100
+ * 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
4101
+ * - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
4102
+ * - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
4103
+ * installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
4104
+ */
4105
+ const emptySchema = Schema.object({});
4106
+ /**
4107
+ * 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
4108
+ * 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
4109
+ * Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
4110
+ */
4111
+ const Config = typeof emptySchema.volatile === "function" ? emptySchema.volatile() : emptySchema;
3916
4112
  function writeJson(res, status, body) {
3917
4113
  const payload = JSON.stringify(body);
3918
4114
  res.writeHead(status, {
@@ -3945,6 +4141,10 @@ function apply(rawContext) {
3945
4141
  const dataDir = dataDirOf(ctx.baseUrl);
3946
4142
  const stateFile = join(dataDir, "state.json");
3947
4143
  log(`data dir: ${dataDir}`);
4144
+ const hostVersion = detectHostVersion(ctx.baseUrl);
4145
+ const host017Plus = isHost017Plus(ctx.baseUrl);
4146
+ const lastHitDock = supportsLastHitDock(ctx.baseUrl);
4147
+ log(`host version: ${hostVersion ?? "未知"} (>=0.1.7: ${host017Plus ? "yes" : "no"}, last-hit dock: ${lastHitDock ? "on" : "off"})`);
3948
4148
  const store = new SupplierConfigStore(stateFile);
3949
4149
  const credentials = new CredentialStore(join(dataDir, "auths"));
3950
4150
  const router = new Router(stateFile, store, log);
@@ -4088,17 +4288,22 @@ function apply(rawContext) {
4088
4288
  return;
4089
4289
  }
4090
4290
  const lv = typeof peek.reasoning_effort === "string" && peek.reasoning_effort !== "" ? peek.reasoning_effort : "auto";
4291
+ const sessionRaw = req.headers["x-dsh-router-session"];
4292
+ const session = typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0;
4091
4293
  await router.chatCompletions({
4092
4294
  rawBody: body,
4093
4295
  stream: !!peek.stream,
4094
4296
  model: typeof peek.model === "string" ? peek.model : "",
4095
- lv
4297
+ lv,
4298
+ ...session === void 0 ? {} : { session }
4096
4299
  }, res);
4097
4300
  }));
4098
4301
  route(`${ROUTER_API_BASE}/health`, (_req, res) => {
4099
4302
  const { suppliers } = router.status();
4100
4303
  writeJson(res, 200, {
4101
4304
  ok: true,
4305
+ hostVersion: hostVersion ?? "",
4306
+ lastHitDock,
4102
4307
  suppliers: suppliers.map((s) => {
4103
4308
  const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
4104
4309
  return {
@@ -4143,6 +4348,33 @@ function apply(rawContext) {
4143
4348
  store.setAccountName(body.supplier, body.uid, typeof body.name === "string" ? body.name : "");
4144
4349
  writeJson(res, 200, { ok: true });
4145
4350
  });
4351
+ route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
4352
+ const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? void 0;
4353
+ const last = router.usage.lastHit(session);
4354
+ if (last === void 0) {
4355
+ writeJson(res, 200, {
4356
+ ok: true,
4357
+ hit: null
4358
+ });
4359
+ return;
4360
+ }
4361
+ const { suppliers } = router.status();
4362
+ const acct = suppliers.find((s) => s.id === last.supplier)?.accounts.find((a) => a.uid === last.uid);
4363
+ const named = last.uid === void 0 || last.uid === "" ? void 0 : store.getAccountName(last.supplier, last.uid);
4364
+ writeJson(res, 200, {
4365
+ ok: true,
4366
+ hit: {
4367
+ supplier: last.supplier,
4368
+ model: last.model,
4369
+ requested: last.requested,
4370
+ uid: last.uid,
4371
+ account: named ?? acct?.nickname ?? (last.uid === void 0 || last.uid === "" ? void 0 : last.uid),
4372
+ credits: acct?.credits,
4373
+ ok: last.ok,
4374
+ ts: last.ts
4375
+ }
4376
+ });
4377
+ });
4146
4378
  route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
4147
4379
  try {
4148
4380
  writeJson(res, 200, {
@@ -4176,7 +4408,7 @@ function apply(rawContext) {
4176
4408
  });
4177
4409
  return;
4178
4410
  }
4179
- const result = router.createCombo(body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
4411
+ const result = router.createCombo(body.name ?? "", body.models ?? []);
4180
4412
  writeJson(res, result.ok ? 200 : 400, result);
4181
4413
  });
4182
4414
  route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
@@ -4190,7 +4422,7 @@ function apply(rawContext) {
4190
4422
  });
4191
4423
  return;
4192
4424
  }
4193
- const result = router.updateCombo(body.id ?? "", body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
4425
+ const result = router.updateCombo(body.id ?? "", body.name ?? "", body.models ?? []);
4194
4426
  writeJson(res, result.ok ? 200 : 400, result);
4195
4427
  });
4196
4428
  route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
@@ -4341,32 +4573,6 @@ function apply(rawContext) {
4341
4573
  }).filter((e) => e !== void 0)
4342
4574
  });
4343
4575
  });
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
4576
  /** 解析并校验周期参数(不接受任意字符串)。 */
4371
4577
  const periodOf = (raw) => {
4372
4578
  return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
@@ -4391,7 +4597,10 @@ function apply(rawContext) {
4391
4597
  bySupplier: s.bySupplier,
4392
4598
  byModel: s.byModel,
4393
4599
  byRequested: s.byRequested,
4394
- recent: router.usage.recentList(20)
4600
+ recent: router.usage.recentList(20).map((r) => ({
4601
+ ...r,
4602
+ connection: r.uid === void 0 || r.uid === "" ? "" : store.getAccountName(r.supplier, r.uid) ?? r.uid
4603
+ }))
4395
4604
  });
4396
4605
  });
4397
4606
  route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
@@ -4412,53 +4621,57 @@ function apply(rawContext) {
4412
4621
  router.usage.clear();
4413
4622
  writeJson(res, 200, { ok: true });
4414
4623
  });
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})`);
4624
+ const settingsNs = ctx.fiber?.entry?.options?.id ?? "dsh-router";
4625
+ if (ctx.llm !== void 0) try {
4626
+ const providerReg = ctx.llm.registerConfigurableProviders([{
4627
+ provider: "router",
4628
+ displayName: "Router",
4629
+ settingsNs,
4630
+ settingsPath: []
4631
+ }]);
4632
+ disposers.push(providerReg);
4633
+ disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
4634
+ return (await router.combos()).map((c) => ({ id: c.name }));
4635
+ }));
4636
+ const routerSettingsSchema = Schema.object({});
4637
+ if (isHost017Plus(ctx.baseUrl)) log(`settings section driven by exported Config (${settingsNs})`);
4638
+ else ctx.inject(["settings"], (sctx) => {
4639
+ const settings = sctx.settings;
4640
+ if (settings === void 0) {
4641
+ log("settings service absent — skip Router settings-section registration");
4642
+ return;
4643
+ }
4644
+ if (settings.installSection === void 0) {
4645
+ log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`);
4646
+ return;
4647
+ }
4648
+ settings.installSection(rawContext, settingsNs, routerSettingsSchema, {}, {
4649
+ setSource: () => {},
4650
+ onChange: () => {}
4440
4651
  });
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");
4652
+ log(`Router settings section registered (${settingsNs})`);
4653
+ });
4654
+ disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
4655
+ const w = router.comboContextWindow(c);
4656
+ return {
4657
+ id: c.name,
4658
+ ...w !== void 0 ? { contextWindow: w } : {}
4659
+ };
4660
+ }) }, () => ctx.get("attachments"))));
4661
+ log("llm provider (Router) + discovery + adapter registered ok");
4662
+ (async () => {
4663
+ for (const c of await router.combos()) for (const ref of c.models) {
4664
+ const at = ref.indexOf(",");
4665
+ if (at < 0) continue;
4666
+ try {
4667
+ await router.modelsOf(ref.slice(0, at));
4668
+ } catch {}
4669
+ }
4670
+ })().catch(() => {});
4671
+ } catch (err) {
4672
+ ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
4673
+ }
4674
+ else log("llm service NOT available — skip 设置-模型 registration");
4462
4675
  route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
4463
4676
  let directory = "n/a";
4464
4677
  let registeredProviders = "n/a";
@@ -4485,7 +4698,7 @@ function apply(rawContext) {
4485
4698
  directory,
4486
4699
  registeredProviders,
4487
4700
  settingsNamespaces,
4488
- routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
4701
+ routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs)
4489
4702
  });
4490
4703
  });
4491
4704
  ctx.effect(() => () => {
@@ -4497,4 +4710,4 @@ function apply(rawContext) {
4497
4710
  }, "dsh-router: teardown");
4498
4711
  }
4499
4712
  //#endregion
4500
- export { apply, inject, name };
4713
+ export { Config, apply, inject, name };