dsh-router-core 0.3.20 → 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 (50) 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 +398 -176
  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 +128 -1
  34. package/src/llm/adapter.ts +48 -3
  35. package/src/router/account-pool.test.ts +189 -83
  36. package/src/router/account-pool.ts +120 -31
  37. package/src/router/disconnect-leak.test.ts +50 -0
  38. package/src/router/index.ts +38 -31
  39. package/src/router/models-cache.test.ts +9 -9
  40. package/src/router/precise-dispatch.test.ts +6 -6
  41. package/src/router/prefix-affinity.ts +22 -1
  42. package/src/router/types.ts +7 -5
  43. package/src/router/usage-store.ts +16 -0
  44. package/src/router/usage.test.ts +1 -1
  45. package/src/shared.ts +13 -24
  46. package/src/supplier-config.ts +2 -13
  47. package/src/suppliers/contract.ts +2 -2
  48. package/src/suppliers/loader.test.ts +2 -2
  49. package/src/suppliers/registry.ts +1 -20
  50. 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
  * 模型列表缓存有效期。
@@ -1573,7 +1669,16 @@ function normalizeSSEStream(stream) {
1573
1669
  function isDoneFrame(frame) {
1574
1670
  return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
1575
1671
  }
1576
- /** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
1672
+ /**
1673
+ * 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
1674
+ *
1675
+ * 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
1676
+ * id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
1677
+ * 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
1678
+ * null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
1679
+ * 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
1680
+ * 「本帧没给新值」,一并剥掉。
1681
+ */
1577
1682
  function fixFrame(frame) {
1578
1683
  if (!frame.includes("tool_calls")) return frame;
1579
1684
  return frame.split("\n").map((line) => {
@@ -1589,12 +1694,12 @@ function fixFrame(frame) {
1589
1694
  let changed = false;
1590
1695
  const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
1591
1696
  if (Array.isArray(tcs)) for (const tc of tcs) {
1592
- if (tc.id === "") {
1697
+ if (tc.id === "" || tc.id === null) {
1593
1698
  delete tc.id;
1594
1699
  changed = true;
1595
1700
  }
1596
1701
  const fn = tc.function;
1597
- if (fn !== null && typeof fn === "object" && fn.name === "") {
1702
+ if (fn !== null && typeof fn === "object" && (fn.name === "" || fn.name === null)) {
1598
1703
  delete fn.name;
1599
1704
  changed = true;
1600
1705
  }
@@ -1744,9 +1849,7 @@ var Router = class {
1744
1849
  suppliers = [];
1745
1850
  combosFp = "";
1746
1851
  customCombos = [];
1747
- /** round-robin 轮转游标(按组合 id 记忆)。 */
1748
- rrCursors = /* @__PURE__ */ new Map();
1749
- /** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
1852
+ /** 通用供应商配置(连接池顺序、模型启用、别名)。 */
1750
1853
  store;
1751
1854
  /** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
1752
1855
  modelsCache = /* @__PURE__ */ new Map();
@@ -1792,7 +1895,6 @@ var Router = class {
1792
1895
  return {
1793
1896
  id: c.id,
1794
1897
  name: typeof c.name === "string" ? c.name : c.id,
1795
- strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
1796
1898
  models
1797
1899
  };
1798
1900
  }).filter((c) => c.models.length > 0);
@@ -1989,9 +2091,6 @@ var Router = class {
1989
2091
  validModels(models) {
1990
2092
  return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
1991
2093
  }
1992
- validStrategy(strategy) {
1993
- return strategy === "fallback" || strategy === "round-robin";
1994
- }
1995
2094
  /** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
1996
2095
  * 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
1997
2096
  * (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
@@ -2012,8 +2111,8 @@ var Router = class {
2012
2111
  return s === void 0 ? modelId : `${s.id},${modelId}`;
2013
2112
  });
2014
2113
  }
2015
- /** 创建组合(name 唯一,非 default)。 */
2016
- createCombo(name, strategy, models) {
2114
+ /** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
2115
+ createCombo(name, models) {
2017
2116
  const clean = name.trim();
2018
2117
  if (clean === "" || clean === "default") return {
2019
2118
  ok: false,
@@ -2031,14 +2130,9 @@ var Router = class {
2031
2130
  ok: false,
2032
2131
  error: "至少需要一个模型"
2033
2132
  };
2034
- if (!this.validStrategy(strategy)) return {
2035
- ok: false,
2036
- error: "策略无效"
2037
- };
2038
2133
  const combo = {
2039
2134
  id: clean,
2040
2135
  name: clean,
2041
- strategy,
2042
2136
  models: this.normalizeModelIds(models)
2043
2137
  };
2044
2138
  this.customCombos.push(combo);
@@ -2049,7 +2143,7 @@ var Router = class {
2049
2143
  };
2050
2144
  }
2051
2145
  /** 更新组合(按 id)。 */
2052
- updateCombo(id, name, strategy, models) {
2146
+ updateCombo(id, name, models) {
2053
2147
  const target = this.customCombos.find((c) => c.id === id);
2054
2148
  if (!target) return {
2055
2149
  ok: false,
@@ -2072,12 +2166,7 @@ var Router = class {
2072
2166
  ok: false,
2073
2167
  error: "至少需要一个模型"
2074
2168
  };
2075
- if (!this.validStrategy(strategy)) return {
2076
- ok: false,
2077
- error: "策略无效"
2078
- };
2079
2169
  target.name = clean;
2080
- target.strategy = strategy;
2081
2170
  target.models = this.normalizeModelIds(models);
2082
2171
  this.saveCombos();
2083
2172
  return { ok: true };
@@ -2134,15 +2223,14 @@ var Router = class {
2134
2223
  durationMs: Date.now() - probe.startedAt,
2135
2224
  ttfbMs: probe.ttfbMs,
2136
2225
  ...ok && probe.uid !== "" ? { uid: probe.uid } : {},
2226
+ ...req.session === void 0 ? {} : { session: req.session },
2137
2227
  ...error === void 0 ? {} : { error }
2138
2228
  }, usage);
2139
2229
  };
2140
2230
  const combo = this.comboByName(req.model);
2141
2231
  if (combo) {
2142
- const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
2143
- if (combo.strategy === "round-robin") this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1);
2144
2232
  for (let i = 0; i < combo.models.length; i++) {
2145
- const model = combo.models[(start + i) % combo.models.length];
2233
+ const model = combo.models[i];
2146
2234
  if (model === void 0) continue;
2147
2235
  const trace = { attempts: 0 };
2148
2236
  if (await this.chatWithModel(req, res, model, probe, trace)) {
@@ -2225,7 +2313,7 @@ var Router = class {
2225
2313
  outTrace.lastError = trace.lastError;
2226
2314
  outTrace.lastState = trace.lastState;
2227
2315
  }
2228
- 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);
2229
2317
  return served;
2230
2318
  }
2231
2319
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
@@ -2262,9 +2350,11 @@ var Router = class {
2262
2350
  }
2263
2351
  const messages = reqMessages(req.rawBody);
2264
2352
  const tried = /* @__PURE__ */ new Set();
2353
+ let pickWhy = "";
2265
2354
  for (;;) {
2266
- 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);
2267
2356
  if (uid === void 0) return false;
2357
+ pickWhy = pool.lastWhy;
2268
2358
  tried.add(uid);
2269
2359
  const r = await s.chatOnce(uid, req.lv ?? "auto", req);
2270
2360
  if (!r.ok) {
@@ -2285,14 +2375,17 @@ var Router = class {
2285
2375
  continue;
2286
2376
  }
2287
2377
  pool.noteSuccess(uid, req.model);
2288
- if (trace !== void 0) trace.uid = uid;
2378
+ if (trace !== void 0) {
2379
+ trace.uid = uid;
2380
+ trace.pickWhy = pickWhy;
2381
+ }
2289
2382
  if (probe !== void 0) {
2290
2383
  probe.supplier = s.id;
2291
2384
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2292
2385
  probe.uid = uid;
2293
2386
  }
2294
2387
  if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
2295
- pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
2388
+ pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
2296
2389
  return true;
2297
2390
  }
2298
2391
  pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
@@ -2381,6 +2474,10 @@ var Router = class {
2381
2474
  * 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
2382
2475
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
2383
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' 分支。
2384
2481
  */
2385
2482
  /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
2386
2483
  const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
@@ -2452,6 +2549,19 @@ async function wireMessages(options, system, attachments) {
2452
2549
  });
2453
2550
  continue;
2454
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
+ }
2455
2565
  const toolResults = message.content.filter((b) => b.type === "tool-result");
2456
2566
  const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
2457
2567
  if (ownParts.length > 0 || toolResults.length === 0) {
@@ -2733,9 +2843,9 @@ async function* translateSse(payloads) {
2733
2843
  blockType: "tool-call"
2734
2844
  };
2735
2845
  }
2736
- if (call.id !== void 0) block.callId = call.id;
2737
- if (call.function?.name !== void 0) block.name = call.function.name;
2738
- const fragment = call.function?.arguments ?? "";
2846
+ if (typeof call.id === "string" && call.id !== "") block.callId = call.id;
2847
+ if (typeof call.function?.name === "string" && call.function.name !== "") block.name = call.function.name;
2848
+ const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
2739
2849
  block.text += fragment;
2740
2850
  yield {
2741
2851
  type: "tool-call-delta",
@@ -2904,7 +3014,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
2904
3014
  headers: {
2905
3015
  ...attributionHeaders(),
2906
3016
  "content-type": "application/json",
2907
- accept: "text/event-stream"
3017
+ accept: "text/event-stream",
3018
+ ...options.sessionId === void 0 ? {} : { "x-dsh-router-session": String(options.sessionId) }
2908
3019
  },
2909
3020
  body: JSON.stringify(body),
2910
3021
  signal: controller.signal
@@ -3503,29 +3614,6 @@ function supplierRoutes(base, loaded, store, router) {
3503
3614
  writeJson$1(res, 200, { ok: true });
3504
3615
  }
3505
3616
  });
3506
- routes.push({
3507
- kind: "exact",
3508
- path: `${p}/pool/strategy`,
3509
- handler: async (req, res) => {
3510
- if (req.method === "GET") {
3511
- writeJson$1(res, 200, {
3512
- ok: true,
3513
- strategy: store.get(s.id).poolStrategy
3514
- });
3515
- return;
3516
- }
3517
- const body = JSON.parse(await readBody$1(req));
3518
- if (body.strategy !== "fallback" && body.strategy !== "round-robin") {
3519
- writeJson$1(res, 400, {
3520
- ok: false,
3521
- error: "策略无效"
3522
- });
3523
- return;
3524
- }
3525
- store.setPoolStrategy(s.id, body.strategy);
3526
- writeJson$1(res, 200, { ok: true });
3527
- }
3528
- });
3529
3617
  routes.push({
3530
3618
  kind: "exact",
3531
3619
  path: `${p}/models/fetch`,
@@ -3786,6 +3874,98 @@ function dshHome() {
3786
3874
  return resolve(value);
3787
3875
  }
3788
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
3789
3969
  //#region src/ext/store.ts
3790
3970
  /**
3791
3971
  * 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
@@ -3904,6 +4084,31 @@ var ExtStore = class {
3904
4084
  const name = "dsh-router-core";
3905
4085
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
3906
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;
3907
4112
  function writeJson(res, status, body) {
3908
4113
  const payload = JSON.stringify(body);
3909
4114
  res.writeHead(status, {
@@ -3936,6 +4141,10 @@ function apply(rawContext) {
3936
4141
  const dataDir = dataDirOf(ctx.baseUrl);
3937
4142
  const stateFile = join(dataDir, "state.json");
3938
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"})`);
3939
4148
  const store = new SupplierConfigStore(stateFile);
3940
4149
  const credentials = new CredentialStore(join(dataDir, "auths"));
3941
4150
  const router = new Router(stateFile, store, log);
@@ -4079,17 +4288,22 @@ function apply(rawContext) {
4079
4288
  return;
4080
4289
  }
4081
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;
4082
4293
  await router.chatCompletions({
4083
4294
  rawBody: body,
4084
4295
  stream: !!peek.stream,
4085
4296
  model: typeof peek.model === "string" ? peek.model : "",
4086
- lv
4297
+ lv,
4298
+ ...session === void 0 ? {} : { session }
4087
4299
  }, res);
4088
4300
  }));
4089
4301
  route(`${ROUTER_API_BASE}/health`, (_req, res) => {
4090
4302
  const { suppliers } = router.status();
4091
4303
  writeJson(res, 200, {
4092
4304
  ok: true,
4305
+ hostVersion: hostVersion ?? "",
4306
+ lastHitDock,
4093
4307
  suppliers: suppliers.map((s) => {
4094
4308
  const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
4095
4309
  return {
@@ -4134,6 +4348,33 @@ function apply(rawContext) {
4134
4348
  store.setAccountName(body.supplier, body.uid, typeof body.name === "string" ? body.name : "");
4135
4349
  writeJson(res, 200, { ok: true });
4136
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
+ });
4137
4378
  route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
4138
4379
  try {
4139
4380
  writeJson(res, 200, {
@@ -4167,7 +4408,7 @@ function apply(rawContext) {
4167
4408
  });
4168
4409
  return;
4169
4410
  }
4170
- const result = router.createCombo(body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
4411
+ const result = router.createCombo(body.name ?? "", body.models ?? []);
4171
4412
  writeJson(res, result.ok ? 200 : 400, result);
4172
4413
  });
4173
4414
  route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
@@ -4181,7 +4422,7 @@ function apply(rawContext) {
4181
4422
  });
4182
4423
  return;
4183
4424
  }
4184
- const result = router.updateCombo(body.id ?? "", body.name ?? "", body.strategy ?? "fallback", body.models ?? []);
4425
+ const result = router.updateCombo(body.id ?? "", body.name ?? "", body.models ?? []);
4185
4426
  writeJson(res, result.ok ? 200 : 400, result);
4186
4427
  });
4187
4428
  route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
@@ -4332,32 +4573,6 @@ function apply(rawContext) {
4332
4573
  }).filter((e) => e !== void 0)
4333
4574
  });
4334
4575
  });
4335
- const extDispatch = ctx.webServer.register({
4336
- kind: "prefix",
4337
- path: `${ROUTER_API_BASE}/ext/`,
4338
- handler: async (req, res) => {
4339
- const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4340
- const mount = `${ROUTER_API_BASE}/ext/`;
4341
- const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
4342
- const slash = rest.indexOf("/");
4343
- const extId = slash < 0 ? rest : rest.slice(0, slash);
4344
- const subpath = slash < 0 ? "" : rest.slice(slash + 1);
4345
- if (extId === "") {
4346
- writeJson(res, 404, {
4347
- ok: false,
4348
- error: "extension not found"
4349
- });
4350
- return;
4351
- }
4352
- const ext = exts[extId];
4353
- if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
4354
- writeJson(res, 404, {
4355
- ok: false,
4356
- error: "extension api not found"
4357
- });
4358
- }
4359
- });
4360
- disposers.push(extDispatch);
4361
4576
  /** 解析并校验周期参数(不接受任意字符串)。 */
4362
4577
  const periodOf = (raw) => {
4363
4578
  return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
@@ -4382,7 +4597,10 @@ function apply(rawContext) {
4382
4597
  bySupplier: s.bySupplier,
4383
4598
  byModel: s.byModel,
4384
4599
  byRequested: s.byRequested,
4385
- 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
+ }))
4386
4604
  });
4387
4605
  });
4388
4606
  route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
@@ -4403,53 +4621,57 @@ function apply(rawContext) {
4403
4621
  router.usage.clear();
4404
4622
  writeJson(res, 200, { ok: true });
4405
4623
  });
4406
- if (ctx.llm !== void 0) {
4407
- const LLM_NS = "llm-dsh-router";
4408
- try {
4409
- const providerReg = ctx.llm.registerConfigurableProviders([{
4410
- provider: "router",
4411
- displayName: "Router",
4412
- settingsNs: LLM_NS,
4413
- settingsPath: []
4414
- }]);
4415
- disposers.push(providerReg);
4416
- disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
4417
- return (await router.combos()).map((c) => ({ id: c.name }));
4418
- }));
4419
- const routerSettingsSchema = Schema.object({});
4420
- ctx.inject(["settings"], (sctx) => {
4421
- const settings = sctx.settings;
4422
- if (settings === void 0) {
4423
- log("settings service absent — skip Router settings-section registration");
4424
- return;
4425
- }
4426
- settings.installSection(rawContext, LLM_NS, routerSettingsSchema, {}, {
4427
- setSource: () => {},
4428
- onChange: () => {}
4429
- });
4430
- 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: () => {}
4431
4651
  });
4432
- disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
4433
- const w = router.comboContextWindow(c);
4434
- return {
4435
- id: c.name,
4436
- ...w !== void 0 ? { contextWindow: w } : {}
4437
- };
4438
- }) }, () => ctx.get("attachments"))));
4439
- log("llm provider (Router) + discovery + adapter registered ok");
4440
- (async () => {
4441
- for (const c of await router.combos()) for (const ref of c.models) {
4442
- const at = ref.indexOf(",");
4443
- if (at < 0) continue;
4444
- try {
4445
- await router.modelsOf(ref.slice(0, at));
4446
- } catch {}
4447
- }
4448
- })().catch(() => {});
4449
- } catch (err) {
4450
- ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
4451
- }
4452
- } 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");
4453
4675
  route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
4454
4676
  let directory = "n/a";
4455
4677
  let registeredProviders = "n/a";
@@ -4476,7 +4698,7 @@ function apply(rawContext) {
4476
4698
  directory,
4477
4699
  registeredProviders,
4478
4700
  settingsNamespaces,
4479
- routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
4701
+ routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs)
4480
4702
  });
4481
4703
  });
4482
4704
  ctx.effect(() => () => {
@@ -4488,4 +4710,4 @@ function apply(rawContext) {
4488
4710
  }, "dsh-router: teardown");
4489
4711
  }
4490
4712
  //#endregion
4491
- export { apply, inject, name };
4713
+ export { Config, apply, inject, name };