dsh-router-core 0.4.0 → 0.4.2

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 (99) hide show
  1. package/README.md +174 -38
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +29 -6
  4. package/docs/suppliers.md +28 -5
  5. package/lib/client-registry.js +939 -132
  6. package/lib/client.js +939 -132
  7. package/lib/ext-test/index.js +47 -0
  8. package/lib/ext-test/locale/en.json +6 -0
  9. package/lib/ext-test/locale/zh.json +6 -0
  10. package/lib/ext-test/package.json +5 -0
  11. package/lib/http-state-C1vb3gW2.js +48 -0
  12. package/lib/index.js +838 -89
  13. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  14. package/lib/suppliers/nvidia/locale/en.json +6 -0
  15. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  16. package/lib/suppliers/nvidia/package.json +5 -0
  17. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  18. package/lib/suppliers/opencode/locale/en.json +6 -0
  19. package/lib/suppliers/opencode/locale/zh.json +6 -0
  20. package/lib/suppliers/opencode/package.json +5 -0
  21. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  22. package/lib/suppliers/openrouter/locale/en.json +6 -0
  23. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  24. package/lib/suppliers/openrouter/package.json +5 -0
  25. package/lib/types/ext/contract.d.ts +9 -0
  26. package/lib/types/ext-test/index.d.ts +21 -0
  27. package/lib/types/ext-test/plugin.d.ts +23 -0
  28. package/lib/types/host-base-url.d.ts +31 -0
  29. package/lib/types/llm/adapter.d.ts +40 -4
  30. package/lib/types/router/index.d.ts +61 -8
  31. package/lib/types/shared.d.ts +12 -0
  32. package/lib/types/supplier-config.d.ts +15 -0
  33. package/lib/types/suppliers/contract.d.ts +35 -2
  34. package/lib/types/suppliers/http-state.d.ts +23 -0
  35. package/lib/types/suppliers/loader.d.ts +6 -4
  36. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  37. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  38. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  39. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  40. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  41. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  42. package/lib/types/suppliers/probe.d.ts +160 -0
  43. package/lib/types/suppliers/registry.d.ts +3 -1
  44. package/package.json +39 -4
  45. package/src/client/ExtDetail.tsx +21 -22
  46. package/src/client/ExtTab.tsx +25 -42
  47. package/src/client/ExtTestPanel.tsx +392 -0
  48. package/src/client/RouterComponentsSection.tsx +241 -0
  49. package/src/client/RouterView.tsx +12 -5
  50. package/src/client/comp-button-scope.test.ts +58 -0
  51. package/src/client/comp-panel-layout.test.ts +58 -0
  52. package/src/client/env.d.ts +38 -1
  53. package/src/client/ext-panels.test.ts +44 -0
  54. package/src/client/ext-panels.ts +49 -0
  55. package/src/client/index.tsx +41 -0
  56. package/src/client/model-choice.test.ts +41 -0
  57. package/src/client/model-choice.ts +37 -0
  58. package/src/client/router-components.test.ts +114 -0
  59. package/src/client/router-components.ts +104 -0
  60. package/src/client/router.css +490 -9
  61. package/src/client/supplier-visibility.test.ts +57 -0
  62. package/src/client/supplier-visibility.ts +63 -0
  63. package/src/ext/contract.ts +9 -0
  64. package/src/ext-test/ext-test.test.ts +60 -0
  65. package/src/ext-test/index.ts +35 -0
  66. package/src/ext-test/locale/en.json +6 -0
  67. package/src/ext-test/locale/zh.json +6 -0
  68. package/src/ext-test/plugin.ts +34 -0
  69. package/src/ext-test/row.json +5 -0
  70. package/src/host-base-url.test.ts +129 -0
  71. package/src/host-base-url.ts +43 -0
  72. package/src/index.ts +46 -11
  73. package/src/llm/adapter.test.ts +101 -4
  74. package/src/llm/adapter.ts +89 -20
  75. package/src/router/disconnect-leak.test.ts +7 -12
  76. package/src/router/first-byte-budget.test.ts +160 -0
  77. package/src/router/index.ts +255 -35
  78. package/src/router/models-cache.test.ts +45 -0
  79. package/src/router/supplier-switch.test.ts +220 -0
  80. package/src/shared.ts +12 -0
  81. package/src/supplier-config.ts +32 -1
  82. package/src/suppliers/contract.ts +54 -2
  83. package/src/suppliers/loader.test.ts +36 -2
  84. package/src/suppliers/loader.ts +33 -18
  85. package/src/suppliers/nvidia/index.ts +27 -0
  86. package/src/suppliers/nvidia/locale/en.json +6 -0
  87. package/src/suppliers/nvidia/locale/zh.json +6 -0
  88. package/src/suppliers/nvidia/row.json +5 -0
  89. package/src/suppliers/opencode/index.ts +27 -0
  90. package/src/suppliers/opencode/locale/en.json +6 -0
  91. package/src/suppliers/opencode/locale/zh.json +6 -0
  92. package/src/suppliers/opencode/row.json +5 -0
  93. package/src/suppliers/openrouter/index.ts +27 -0
  94. package/src/suppliers/openrouter/locale/en.json +6 -0
  95. package/src/suppliers/openrouter/locale/zh.json +6 -0
  96. package/src/suppliers/openrouter/row.json +5 -0
  97. package/src/suppliers/probe.test.ts +266 -0
  98. package/src/suppliers/probe.ts +475 -0
  99. package/src/suppliers/registry.ts +102 -3
package/lib/index.js CHANGED
@@ -561,6 +561,7 @@ var SupplierConfigStore = class {
561
561
  let cfg = this.bySupplier.get(supplierId);
562
562
  if (!cfg) {
563
563
  cfg = {
564
+ enabled: true,
564
565
  alias: "",
565
566
  disabled: [],
566
567
  custom: [],
@@ -588,6 +589,22 @@ var SupplierConfigStore = class {
588
589
  this.saveLocked();
589
590
  return { ok: true };
590
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
+ }
591
608
  setModelEnabled(supplierId, modelId, enabled) {
592
609
  const cfg = this.get(supplierId);
593
610
  cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
@@ -666,6 +683,7 @@ var SupplierConfigStore = class {
666
683
  return;
667
684
  }
668
685
  for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
686
+ enabled: typeof c.enabled === "boolean" ? c.enabled : true,
669
687
  alias: typeof c.alias === "string" ? c.alias : "",
670
688
  disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
671
689
  custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
@@ -678,6 +696,7 @@ var SupplierConfigStore = class {
678
696
  if (this.fp === "") return;
679
697
  const f = { suppliers: {} };
680
698
  for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
699
+ enabled: cfg.enabled,
681
700
  alias: cfg.alias,
682
701
  disabled: [...cfg.disabled],
683
702
  custom: [...cfg.custom],
@@ -1453,6 +1472,99 @@ function isTransient(state) {
1453
1472
  return state === "transport" || state === "unavailable";
1454
1473
  }
1455
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
+ /**
1456
1568
  * 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
1457
1569
  * undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
1458
1570
  */
@@ -1723,8 +1835,11 @@ function fixFrame(frame) {
1723
1835
  *
1724
1836
  * 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
1725
1837
  * 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
1838
+ *
1839
+ * @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
1840
+ * 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
1726
1841
  */
1727
- async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1842
+ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0, firstByteTimeoutMs = FIRST_BYTE_BUDGET_MS) {
1728
1843
  if (!r.ok) return false;
1729
1844
  if (!("stream" in r)) {
1730
1845
  if (probe !== void 0) probe.tokens = usageFromJsonBody(r.body);
@@ -1735,12 +1850,13 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1735
1850
  res.end(r.body);
1736
1851
  return true;
1737
1852
  }
1853
+ const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs);
1738
1854
  if (!wantsStream) {
1739
1855
  let body;
1740
1856
  try {
1741
- body = await aggregateSSE(r.stream);
1857
+ body = await aggregateSSE(src);
1742
1858
  } catch {
1743
- await r.stream.cancel().catch(() => {});
1859
+ await src.cancel().catch(() => {});
1744
1860
  return false;
1745
1861
  }
1746
1862
  if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
@@ -1751,7 +1867,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1751
1867
  res.end(body);
1752
1868
  return true;
1753
1869
  }
1754
- const tapped = probe === void 0 ? void 0 : tapStreamUsage(r.stream, startedAt);
1870
+ const tapped = probe === void 0 ? void 0 : tapStreamUsage(src, startedAt);
1755
1871
  let wroteAny = false;
1756
1872
  /** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
1757
1873
  let dead = false;
@@ -1815,7 +1931,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
1815
1931
  abort.abort();
1816
1932
  };
1817
1933
  res.once("close", onClose);
1818
- const norm = normalizeSSEStream(tapped?.stream ?? r.stream);
1934
+ const norm = normalizeSSEStream(tapped?.stream ?? src);
1819
1935
  try {
1820
1936
  await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
1821
1937
  } catch {} finally {
@@ -1846,6 +1962,12 @@ function extractUpstreamError(body) {
1846
1962
  }
1847
1963
  /** 路由器。 */
1848
1964
  var Router = class {
1965
+ /**
1966
+ * **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
1967
+ * 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
1968
+ *
1969
+ * 真正参与路由的只有 `active` 那一份。
1970
+ */
1849
1971
  suppliers = [];
1850
1972
  combosFp = "";
1851
1973
  customCombos = [];
@@ -1860,12 +1982,15 @@ var Router = class {
1860
1982
  modelsInflight = /* @__PURE__ */ new Map();
1861
1983
  /** 请求日志出口(面板/宿主 logger)。 */
1862
1984
  log;
1985
+ /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
1986
+ firstByteBudgetMs;
1863
1987
  /** 用量统计(概览看板)。 */
1864
1988
  usage;
1865
- constructor(stateFile = "", store, log) {
1989
+ constructor(stateFile = "", store, log, opts) {
1866
1990
  this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
1867
1991
  this.store = store ?? new SupplierConfigStore(stateFile);
1868
1992
  this.log = log ?? (() => {});
1993
+ this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS;
1869
1994
  this.usage = new UsageStore(stateFile);
1870
1995
  this.loadCombos();
1871
1996
  }
@@ -1889,7 +2014,7 @@ var Router = class {
1889
2014
  if (slash <= 0) return m;
1890
2015
  const alias = m.slice(0, slash);
1891
2016
  const modelId = m.slice(slash + 1);
1892
- const s = this.suppliers.find((x) => x.getAlias() === alias);
2017
+ const s = this.active.find((x) => x.getAlias() === alias);
1893
2018
  return s === void 0 ? modelId : `${s.id},${modelId}`;
1894
2019
  });
1895
2020
  return {
@@ -1916,6 +2041,20 @@ var Router = class {
1916
2041
  this.suppliers.sort((a, b) => a.priority - b.priority);
1917
2042
  this.store.sync(this.suppliers.map((s) => s.id));
1918
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
+ }
1919
2058
  /** 移除供应商(外部插件卸载时注销)。 */
1920
2059
  removeSupplier(id) {
1921
2060
  const i = this.suppliers.findIndex((s) => s.id === id);
@@ -1925,13 +2064,20 @@ var Router = class {
1925
2064
  s?.dispose();
1926
2065
  return true;
1927
2066
  }
1928
- /** 返回全部供应商状态(面板用)。 */
2067
+ /**
2068
+ * 返回全部供应商状态(面板用)。**含被开关关掉的** —— 面板要能显示它们
2069
+ * (「已关闭」),否则用户在面板上就再也看不到自己装过的供应商。
2070
+ * 开关状态由 `enabled` 字段带出,调用方自己决定要不要只留开着的。
2071
+ */
1929
2072
  status() {
1930
- 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
+ })) };
1931
2077
  }
1932
- /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
2078
+ /** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
1933
2079
  aliases() {
1934
- return this.suppliers.map((s) => ({
2080
+ return this.active.map((s) => ({
1935
2081
  id: s.id,
1936
2082
  name: s.name,
1937
2083
  alias: s.getAlias()
@@ -1949,7 +2095,7 @@ var Router = class {
1949
2095
  seen.add(c.name);
1950
2096
  out.push({ id: c.name });
1951
2097
  }
1952
- for (const s of this.suppliers) try {
2098
+ for (const s of this.active) try {
1953
2099
  const ids = s.customModelIds?.();
1954
2100
  for (const id of ids ?? []) if (!seen.has(id)) {
1955
2101
  seen.add(id);
@@ -2008,7 +2154,7 @@ var Router = class {
2008
2154
  return min;
2009
2155
  }
2010
2156
  async modelsOf(supplierId, force = false) {
2011
- const s = this.suppliers.find((x) => x.id === supplierId);
2157
+ const s = this.active.find((x) => x.id === supplierId);
2012
2158
  if (s === void 0) return [];
2013
2159
  const hit = this.modelsCache.get(supplierId);
2014
2160
  const fresh = hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS;
@@ -2072,7 +2218,7 @@ var Router = class {
2072
2218
  /** 可用模型(按供应商分组,仅启用),面板加模型用。
2073
2219
  * 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
2074
2220
  async supplierModels() {
2075
- return (await Promise.all(this.suppliers.map(async (s) => {
2221
+ return (await Promise.all(this.active.map(async (s) => {
2076
2222
  try {
2077
2223
  const models = (await this.modelsOf(s.id)).filter((m) => m.enabled);
2078
2224
  return {
@@ -2107,7 +2253,7 @@ var Router = class {
2107
2253
  if (slash <= 0) return m;
2108
2254
  const alias = m.slice(0, slash);
2109
2255
  const modelId = m.slice(slash + 1);
2110
- const s = this.suppliers.find((x) => x.getAlias() === alias);
2256
+ const s = this.active.find((x) => x.getAlias() === alias);
2111
2257
  return s === void 0 ? modelId : `${s.id},${modelId}`;
2112
2258
  });
2113
2259
  }
@@ -2233,7 +2379,7 @@ var Router = class {
2233
2379
  const model = combo.models[i];
2234
2380
  if (model === void 0) continue;
2235
2381
  const trace = { attempts: 0 };
2236
- if (await this.chatWithModel(req, res, model, probe, trace)) {
2382
+ if (await this.chatWithModel(req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs)) {
2237
2383
  settle(true);
2238
2384
  return;
2239
2385
  }
@@ -2279,8 +2425,11 @@ var Router = class {
2279
2425
  * @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
2280
2426
  * 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
2281
2427
  * 失败状态没有单一归属)。
2428
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
2429
+ * 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
2430
+ * 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
2282
2431
  */
2283
- async chatWithModel(req, res, model, probe, outTrace) {
2432
+ async chatWithModel(req, res, model, probe, outTrace, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs) {
2284
2433
  const comma = model.indexOf(",");
2285
2434
  const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
2286
2435
  const modelId = comma > 0 ? model.slice(comma + 1) : model;
@@ -2300,14 +2449,15 @@ var Router = class {
2300
2449
  this.logChat(req.model, msg, 0);
2301
2450
  return false;
2302
2451
  }
2303
- const s = this.suppliers.find((x) => x.id === supplierId);
2452
+ const s = this.active.find((x) => x.id === supplierId);
2304
2453
  if (s === void 0) {
2305
- 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);
2306
2456
  return false;
2307
2457
  }
2308
2458
  const t0 = Date.now();
2309
2459
  const trace = { attempts: 0 };
2310
- const served = await this.chatWithSupplier(s, clone, res, trace, probe);
2460
+ const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt);
2311
2461
  if (outTrace !== void 0) {
2312
2462
  outTrace.attempts = trace.attempts;
2313
2463
  outTrace.lastError = trace.lastError;
@@ -2318,7 +2468,7 @@ var Router = class {
2318
2468
  }
2319
2469
  /** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
2320
2470
  supplierByAlias(alias) {
2321
- return this.suppliers.find((s) => s.getAlias() === alias);
2471
+ return this.active.find((s) => s.getAlias() === alias);
2322
2472
  }
2323
2473
  /**
2324
2474
  * 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
@@ -2328,8 +2478,16 @@ var Router = class {
2328
2478
  * 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
2329
2479
  * 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
2330
2480
  * 延迟,目前未做。
2481
+ *
2482
+ * @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
2483
+ * 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
2484
+ * 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
2485
+ */
2486
+ /**
2487
+ * @param onlyUid - 只试这一个连接,**不做账号池回退**。给面板「指定连接」那档用
2488
+ * (诊断单个链接到底通不通);正常请求与普通模型测试都不传,走账号池。
2331
2489
  */
2332
- async chatWithSupplier(s, req, res, trace, probe) {
2490
+ async chatWithSupplier(s, req, res, trace, probe, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs, onlyUid) {
2333
2491
  const pool = s.pool;
2334
2492
  const cfg = this.store.get(s.id);
2335
2493
  if (s.accounts !== void 0 && s.accounts().length === 0) {
@@ -2346,14 +2504,27 @@ var Router = class {
2346
2504
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2347
2505
  probe.uid = "";
2348
2506
  }
2349
- 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());
2350
2508
  }
2351
2509
  const messages = reqMessages(req.rawBody);
2352
2510
  const tried = /* @__PURE__ */ new Set();
2353
2511
  let pickWhy = "";
2354
2512
  for (;;) {
2355
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
2356
- 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
+ }
2357
2528
  pickWhy = pool.lastWhy;
2358
2529
  tried.add(uid);
2359
2530
  const r = await s.chatOnce(uid, req.lv ?? "auto", req);
@@ -2384,7 +2555,7 @@ var Router = class {
2384
2555
  probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
2385
2556
  probe.uid = uid;
2386
2557
  }
2387
- if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
2558
+ if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now())) {
2388
2559
  pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
2389
2560
  return true;
2390
2561
  }
@@ -2396,11 +2567,17 @@ var Router = class {
2396
2567
  }
2397
2568
  }
2398
2569
  }
2399
- /** 测试某供应商的某模型是否可用。
2400
- * 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
2401
- * 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
2402
- async testModel(supplierId, model) {
2403
- 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);
2404
2581
  if (s === void 0) return {
2405
2582
  ok: false,
2406
2583
  error: `unknown supplier ${JSON.stringify(supplierId)}`
@@ -2422,7 +2599,7 @@ var Router = class {
2422
2599
  let served = false;
2423
2600
  const trace = { attempts: 0 };
2424
2601
  try {
2425
- served = await this.chatWithSupplier(s, req, sink, trace);
2602
+ served = await this.chatWithSupplier(s, req, sink, trace, void 0, Date.now() + this.firstByteBudgetMs, onlyUid);
2426
2603
  } catch (err) {
2427
2604
  return {
2428
2605
  ok: false,
@@ -2455,8 +2632,43 @@ var Router = class {
2455
2632
  * `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
2456
2633
  * 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
2457
2634
  * 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
2458
- * 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
2635
+ * 图片字节从 `ctx.attachments` 读取。读不到时按稳定占位文本降级(不让整条消息失败),
2636
+ * 但**必须留痕**:降级原因走 adapter 的 `log` 出口打到宿主 logger。曾经这里是
2637
+ * 一个空 `catch {}`,字段改名导致的读图失败静默了跨两个内测版本(issue #8)。
2459
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
+ * 有测试钉住;真要换成官方实现时,语义等价。
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
+ }
2460
2672
  /**
2461
2673
  * 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
2462
2674
  *
@@ -2491,7 +2703,7 @@ const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
2491
2703
  * 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
2492
2704
  */
2493
2705
  const STREAM_IDLE_TIMEOUT_MS = 3e5;
2494
- async function wireMessages(options, system, attachments) {
2706
+ async function wireMessages(options, system, attachments, log) {
2495
2707
  const out = [];
2496
2708
  /**
2497
2709
  * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
@@ -2551,7 +2763,7 @@ async function wireMessages(options, system, attachments) {
2551
2763
  }
2552
2764
  if (message.role === "tool") {
2553
2765
  const toolCallId = message.toolCallId;
2554
- const parts = await contentParts(message.content, attachments, options.signal);
2766
+ const parts = await contentParts(message.content, attachments, options.signal, log);
2555
2767
  const imageParts = parts.filter((p) => p.type !== "text");
2556
2768
  const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2557
2769
  out.push({
@@ -2563,7 +2775,7 @@ async function wireMessages(options, system, attachments) {
2563
2775
  continue;
2564
2776
  }
2565
2777
  const toolResults = message.content.filter((b) => b.type === "tool-result");
2566
- 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);
2567
2779
  if (ownParts.length > 0 || toolResults.length === 0) {
2568
2780
  flushToolImages();
2569
2781
  out.push({
@@ -2572,7 +2784,7 @@ async function wireMessages(options, system, attachments) {
2572
2784
  });
2573
2785
  }
2574
2786
  for (const result of toolResults) {
2575
- const parts = await contentParts(result.content, attachments, options.signal);
2787
+ const parts = await contentParts(result.content, attachments, options.signal, log);
2576
2788
  const imageParts = parts.filter((p) => p.type !== "text");
2577
2789
  const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
2578
2790
  out.push({
@@ -2603,7 +2815,7 @@ function compactUserContent(parts) {
2603
2815
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
2604
2816
  * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
2605
2817
  */
2606
- async function contentParts(blocks, attachments, signal) {
2818
+ async function contentParts(blocks, attachments, signal, log) {
2607
2819
  const parts = [];
2608
2820
  for (const block of blocks) {
2609
2821
  if (block.type === "text") {
@@ -2616,35 +2828,41 @@ async function contentParts(blocks, attachments, signal) {
2616
2828
  }
2617
2829
  if (block.type === "image") {
2618
2830
  const ref = block.attachment;
2619
- parts.push(...await imageParts(ref, attachments, signal));
2831
+ parts.push(...await imageParts(ref, attachments, signal, log));
2620
2832
  continue;
2621
2833
  }
2622
- 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));
2623
2835
  }
2624
2836
  return parts;
2625
2837
  }
2626
2838
  /** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
2627
- async function imageParts(ref, attachments, signal) {
2628
- if (attachments === void 0) return [{
2629
- type: "text",
2630
- text: imagePlaceholder(ref.attachmentId)
2631
- }];
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
+ };
2632
2858
  try {
2633
- const img = await attachments.readImageRequest({
2634
- attachmentId: ref.attachmentId,
2635
- mediaType: ref.mediaType,
2636
- bytes: ref.bytes,
2637
- width: ref.width,
2638
- height: ref.height
2639
- }, {
2640
- maxPixels: 64e4,
2641
- maxBytes: 1048576
2642
- }, signal);
2859
+ const img = await attachments.readImageRequest(ref0, target, signal);
2643
2860
  return [{
2644
2861
  type: "image_url",
2645
2862
  image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` }
2646
2863
  }];
2647
- } catch {
2864
+ } catch (err) {
2865
+ log?.(`读取图片 ${ref.attachmentId} 失败,已降级为占位文本:${err.message}`);
2648
2866
  return [{
2649
2867
  type: "text",
2650
2868
  text: imagePlaceholder(ref.attachmentId)
@@ -2663,8 +2881,8 @@ function imagePlaceholder(attachmentId) {
2663
2881
  return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`;
2664
2882
  }
2665
2883
  /** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
2666
- async function wireRequest(options, attachments) {
2667
- 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);
2668
2886
  const body = {
2669
2887
  model: options.model,
2670
2888
  messages,
@@ -2942,21 +3160,31 @@ const ROUTER_REASONING_EFFORTS = [
2942
3160
  }
2943
3161
  ];
2944
3162
  var RouterAdapter = class RouterAdapter extends LlmAdapter {
3163
+ /**
3164
+ * 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
3165
+ * 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
3166
+ * issue #9),见 `host-base-url.ts`。
3167
+ */
2945
3168
  baseURL;
2946
3169
  source;
2947
3170
  resolveAttachments;
2948
3171
  /** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
2949
3172
  idleTimeoutMs;
3173
+ /** 降级/失败诊断的出口(宿主接 ctx.logger.warn)。缺省丢弃 —— 仅为测试可注入。 */
3174
+ log;
2950
3175
  /**
2951
3176
  * @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
2952
3177
  * 可注入只为测试能压到几百毫秒,生产不传。
3178
+ * @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
3179
+ * 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
2953
3180
  */
2954
- constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
3181
+ constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS, log) {
2955
3182
  super();
2956
3183
  this.baseURL = baseURL;
2957
3184
  this.source = source;
2958
3185
  this.resolveAttachments = resolveAttachments ?? (() => void 0);
2959
3186
  this.idleTimeoutMs = idleTimeoutMs;
3187
+ this.log = log;
2960
3188
  }
2961
3189
  providerInfo(provider) {
2962
3190
  return {
@@ -3003,13 +3231,14 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
3003
3231
  };
3004
3232
  }
3005
3233
  async *stream(options) {
3006
- const body = await wireRequest(options, this.resolveAttachments());
3234
+ const body = await wireRequest(options, this.resolveAttachments(), this.log);
3007
3235
  const controller = new AbortController();
3008
3236
  const onAbort = () => controller.abort();
3009
3237
  options.signal?.addEventListener("abort", onAbort, { once: true });
3010
3238
  let resp;
3011
3239
  try {
3012
- resp = await fetch(`${this.baseURL}/chat/completions`, {
3240
+ const base = typeof this.baseURL === "function" ? this.baseURL() : this.baseURL;
3241
+ resp = await fetch(`${base}/chat/completions`, {
3013
3242
  method: "POST",
3014
3243
  headers: {
3015
3244
  ...attributionHeaders(),
@@ -3243,12 +3472,18 @@ var KeysStore = class {
3243
3472
  //#endregion
3244
3473
  //#region src/suppliers/loader.ts
3245
3474
  /**
3246
- * 供应商 loader —— 扫描 suppliers 目录下的 *.js,动态 import,按契约包装成
3475
+ * 供应商 loader —— 扫描 **用户目录**下的 *.js,动态 import,按契约包装成
3247
3476
  * 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
3248
3477
  *
3249
- * 加载来源(两处,profile 优先):
3250
- * 1. 内置:<plugin>/lib/suppliers/*.js(随插件分发)
3251
- * 2. 用户:~/.dsh/profiles/web/suppliers/*.js(用户自定义)
3478
+ * 加载来源(两处):
3479
+ * 1. 用户:`~/.dsh/profiles/<profile>/suppliers/*.js`(自定义供应商)
3480
+ * 2. 内置:**插件页「包含的组件」里的行** —— core 自己的 cordis.patch.yml
3481
+ * insert 了三个子路径模块,经 `router.suppliers` 表进来,与独立安装的
3482
+ * 供应商插件同一条通道,核心不区分内外,只看工厂上的 `source` 标签。
3483
+ *
3484
+ * 内置曾经也是「扫 `<plugin>/lib/suppliers/*.js`」(本文件早先的 builtinDir)。
3485
+ * 改成行之后那条路径已删:扫目录拿不到行的身份(插件页的开关/显示名/说明),
3486
+ * 而且关一个内置供应商要手改配置才生效 —— 行才有宿主管的开关。
3252
3487
  */
3253
3488
  function isFn(v) {
3254
3489
  return typeof v === "function";
@@ -3384,7 +3619,27 @@ function scanDir(dir) {
3384
3619
  return [];
3385
3620
  }
3386
3621
  }
3387
- /** 加载全部供应商(内置 + 用户,用户覆盖内置同 id)。 */
3622
+ /**
3623
+ * 同 id 冲突时的优先级:**用户目录 js > 内置行 > 外部插件供应商**。
3624
+ *
3625
+ * 为什么用户目录最高:用户放一个同 id 的 js 就是想替换它,这是「扫目录时代」
3626
+ * 就有的语义(见本文件早先的 `loadSuppliers`:内置被用户版覆盖),内置改成行
3627
+ * 之后不能悄悄反过来。
3628
+ *
3629
+ * 纯函数 + 有测试:这条规则错了不会抛异常,只会让用户的覆盖悄悄失效。
3630
+ */
3631
+ const SUPPLIER_RANK = {
3632
+ user: 2,
3633
+ builtin: 1,
3634
+ external: 0
3635
+ };
3636
+ function supplierWins(incoming, existing) {
3637
+ return SUPPLIER_RANK[incoming] > SUPPLIER_RANK[existing];
3638
+ }
3639
+ /**
3640
+ * 加载用户目录下的供应商。同一个 id 出现多个文件时**后者覆盖前者**(按文件名序),
3641
+ * 与内置共存的裁决交给核心(`registerLoaded` 的优先级:用户目录 js > 行)。
3642
+ */
3388
3643
  async function loadSuppliers(opts) {
3389
3644
  const env = {
3390
3645
  dataDir: opts.dataDir,
@@ -3394,18 +3649,10 @@ async function loadSuppliers(opts) {
3394
3649
  };
3395
3650
  const byId = /* @__PURE__ */ new Map();
3396
3651
  const errors = [];
3397
- const files = [...scanDir(opts.builtinDir).map((f) => ({
3398
- f,
3399
- builtin: true
3400
- })), ...scanDir(opts.userDir).map((f) => ({
3401
- f,
3402
- builtin: false
3403
- }))];
3404
- for (const { f, builtin } of files) try {
3405
- const loaded = await loadOne(f, env, builtin ? "builtin" : "user");
3406
- if (byId.get(loaded.supplier.id) && builtin) continue;
3652
+ for (const f of scanDir(opts.userDir).sort()) try {
3653
+ const loaded = await loadOne(f, env, "user");
3407
3654
  byId.set(loaded.supplier.id, loaded);
3408
- opts.log(`supplier ${loaded.supplier.id} loaded (${builtin ? "builtin" : "user"}) from ${basename(f)}`);
3655
+ opts.log(`supplier ${loaded.supplier.id} loaded (user) from ${basename(f)}`);
3409
3656
  } catch (err) {
3410
3657
  errors.push({
3411
3658
  id: basename(f, ".js"),
@@ -3420,6 +3667,355 @@ async function loadSuppliers(opts) {
3420
3667
  };
3421
3668
  }
3422
3669
  //#endregion
3670
+ //#region src/suppliers/probe.ts
3671
+ /**
3672
+ * 契约成员清单(体检的唯一事实来源)。
3673
+ *
3674
+ * `required: false` = 差异化能力,按存在性暴露端点/UI;`probe: 'skip'` 的理由都写在这里,
3675
+ * 面板会把 `skipReason` 原样显示 —— 用户能知道「有这个能力,只是体检不替你调」。
3676
+ */
3677
+ const SUPPLIER_CONTRACT_MEMBERS = [
3678
+ {
3679
+ key: "id",
3680
+ label: "供应商 id",
3681
+ required: true,
3682
+ probe: "safe"
3683
+ },
3684
+ {
3685
+ key: "name",
3686
+ label: "显示名",
3687
+ required: true,
3688
+ probe: "safe"
3689
+ },
3690
+ {
3691
+ key: "priority",
3692
+ label: "优先级",
3693
+ required: false,
3694
+ probe: "safe"
3695
+ },
3696
+ {
3697
+ key: "icon",
3698
+ label: "图标",
3699
+ required: false,
3700
+ probe: "safe"
3701
+ },
3702
+ {
3703
+ key: "apiKeyHint",
3704
+ label: "API key 提示文案",
3705
+ required: false,
3706
+ probe: "safe"
3707
+ },
3708
+ {
3709
+ key: "status",
3710
+ label: "账号状态",
3711
+ required: true,
3712
+ probe: "safe"
3713
+ },
3714
+ {
3715
+ key: "listModels",
3716
+ label: "模型列表",
3717
+ required: true,
3718
+ probe: "safe"
3719
+ },
3720
+ {
3721
+ key: "chatOnce",
3722
+ label: "调用上游",
3723
+ required: true,
3724
+ probe: "skip",
3725
+ skipReason: "会真的发一次请求(消耗额度)。用上面的「跑一次访问测试」单独测,或在供应商详情里测模型。"
3726
+ },
3727
+ {
3728
+ key: "checkinNow",
3729
+ label: "签到",
3730
+ required: false,
3731
+ probe: "skip",
3732
+ skipReason: "会替这个连接真的去签到。"
3733
+ }
3734
+ ];
3735
+ /** 读原始模块实例(`wrapModule` 挂在 wrapper 上)。 */
3736
+ function rawModule(loaded) {
3737
+ return loaded.supplier.__module ?? {};
3738
+ }
3739
+ /** 读出账号状态列表(只读,不改任何东西)。 */
3740
+ function accountStates(status) {
3741
+ if (typeof status !== "object" || status === null) return [];
3742
+ const accounts = status.accounts;
3743
+ if (!Array.isArray(accounts)) return [];
3744
+ return accounts.flatMap((a) => {
3745
+ const uid = a?.uid;
3746
+ if (typeof uid !== "string") return [];
3747
+ return [{
3748
+ uid,
3749
+ state: typeof a.state === "string" ? a.state : "ok",
3750
+ cooling: a.cooling === true
3751
+ }];
3752
+ });
3753
+ }
3754
+ /** 第一个连接 uid(`checkinNow` 之类要真实连接时用)。 */
3755
+ function firstAccountUid(m) {
3756
+ return accountStates(m.status())[0]?.uid;
3757
+ }
3758
+ /** 账号状态分布:`正常 2、冷却 1`、`session_dead 1` 这样。 */
3759
+ function accountTally(accounts) {
3760
+ const counts = /* @__PURE__ */ new Map();
3761
+ for (const a of accounts) {
3762
+ const key = a.cooling ? "冷却" : a.state === "ok" ? "正常" : a.state;
3763
+ counts.set(key, (counts.get(key) ?? 0) + 1);
3764
+ }
3765
+ return [...counts].map(([k, n]) => `${k} ${n}`).join("、");
3766
+ }
3767
+ /**
3768
+ * 摘要:把返回值压成一行**对排查有用**的文本。
3769
+ *
3770
+ * 原先只说「返回 number」「返回 data:image/svg+xml,%3Csvg…」——等于没说话:
3771
+ * 体检的意义就是让人一眼看出「priority 是几」「图标给了没有」。
3772
+ */
3773
+ function describe(value) {
3774
+ if (value === void 0) return "未提供(可选)";
3775
+ if (value === null) return "返回 null";
3776
+ if (Array.isArray(value)) return `${value.length} 项`;
3777
+ if (typeof value === "string") {
3778
+ if (value.startsWith("data:")) return `已提供(内联 ${value.slice(5, value.indexOf("/") || 12)})`;
3779
+ if (value === "") return "返回空串";
3780
+ return value.length > 48 ? `返回 ${value.slice(0, 48)}…` : `返回 ${value}`;
3781
+ }
3782
+ if (typeof value === "number" || typeof value === "boolean") return `返回 ${value}`;
3783
+ if (typeof value === "object") {
3784
+ const o = value;
3785
+ if (typeof o.ok === "boolean") return o.ok === false ? `ok:false ${String(o.error ?? o.message ?? "")}`.trim() : "ok:true";
3786
+ return Object.keys(o).slice(0, 4).join(", ") || "空对象";
3787
+ }
3788
+ return `返回 ${typeof value}`;
3789
+ }
3790
+ /** 异步取一个值并压成摘要(同步抛错也要接住)。 */
3791
+ async function probeValue(call) {
3792
+ try {
3793
+ return {
3794
+ ok: true,
3795
+ detail: describe(await call())
3796
+ };
3797
+ } catch (err) {
3798
+ return {
3799
+ ok: false,
3800
+ detail: err.message
3801
+ };
3802
+ }
3803
+ }
3804
+ /**
3805
+ *(这里原本有一份「永不实跑」名单:`dispose` / `addApiKey`。
3806
+ * 两者都已移出体检清单,名单随之作废 —— 留着空机制比删掉更糟:
3807
+ * 它看起来像个可配置的开关,其实没有任何成员会命中。
3808
+ * 「为什么要移」见 PROBE_EXCLUDED_MEMBERS。)
3809
+ */
3810
+ /** 深度档用来「不碰真实状态就跑通代码路径」的探针输入。 */
3811
+ const PROBE_UID = "__probe_no_such_uid__";
3812
+ const PROBE_CALLBACK = "https://invalid.example/__probe__";
3813
+ /**
3814
+ * 体检一个已装载的供应商。
3815
+ *
3816
+ * 只跑 `probe: 'safe'` 的成员;`skip` 的只报存在性。绝不代替用户调有副作用的成员。
3817
+ *
3818
+ * @param models - 该供应商当前的模型启用状态(由调用方用 `router.modelsOf` 取,
3819
+ * 走核心缓存不额外打上游)。取不到就传 undefined —— 报告里会如实写「拿不到」,
3820
+ * 不能拿 0 冒充「一个模型都没有」。
3821
+ */
3822
+ async function probeSupplier(loaded, input = {}) {
3823
+ const m = rawModule(loaded);
3824
+ const members = [];
3825
+ for (const member of SUPPLIER_CONTRACT_MEMBERS) {
3826
+ const present = member.key in m ? m[member.key] !== void 0 : false;
3827
+ const base = {
3828
+ key: member.key,
3829
+ label: member.label,
3830
+ required: member.required,
3831
+ present,
3832
+ executed: present ? "ran" : "no"
3833
+ };
3834
+ if (!present) {
3835
+ members.push({
3836
+ ...base,
3837
+ state: member.required ? "fail" : "absent",
3838
+ ...member.required ? { detail: "必填成员缺失,插件不完整" } : {}
3839
+ });
3840
+ continue;
3841
+ }
3842
+ const outcome = member.probe === "safe" ? await runSafeProbe(member.key, m, input) : await runProbedMember(member.key, m, input);
3843
+ members.push({
3844
+ ...base,
3845
+ state: outcome.ok ? "ok" : outcome.detail.startsWith("无法验证") ? "unverified" : "fail",
3846
+ detail: outcome.detail
3847
+ });
3848
+ }
3849
+ const bulkResult = await runBulkRoundTrip(input);
3850
+ input.models;
3851
+ const summary = {
3852
+ total: members.length,
3853
+ implemented: members.filter((x) => x.present).length,
3854
+ ok: members.filter((x) => x.state === "ok").length,
3855
+ fail: members.filter((x) => x.state === "fail").length,
3856
+ absent: members.filter((x) => x.state === "absent").length,
3857
+ unverified: members.filter((x) => x.state === "unverified").length
3858
+ };
3859
+ const listModels = members.find((x) => x.key === "listModels");
3860
+ return {
3861
+ supplier: loaded.supplier.id,
3862
+ name: loaded.supplier.name,
3863
+ members,
3864
+ core: await coreReport(input, listModels?.state === "ok", bulkResult),
3865
+ summary: {
3866
+ ...summary,
3867
+ ran: members.filter((x) => x.executed === "ran" && x.present).length,
3868
+ bulk: bulkResult.ran ? bulkResult.ok === true ? "ok" : "fail" : "unverified"
3869
+ }
3870
+ };
3871
+ }
3872
+ /**
3873
+ * 实跑「有副作用但可以安全试」的成员:要么用**无害探针输入**跑通代码路径,要么用
3874
+ * **连接池里的真实连接**真跑一次(前提由使用者保证:池里已放好可用 token)。
3875
+ */
3876
+ async function runProbedMember(key, m, input) {
3877
+ try {
3878
+ switch (key) {
3879
+ case "chatOnce": {
3880
+ if (input.runChatOnce === void 0) return {
3881
+ ok: false,
3882
+ detail: "无法验证:没有发起真实调用的入口"
3883
+ };
3884
+ const model = input.models?.find((x) => x.enabled)?.id ?? input.models?.[0]?.id;
3885
+ if (model === void 0) return {
3886
+ ok: false,
3887
+ detail: "无法验证:没有已启用的模型(先启用至少一个)"
3888
+ };
3889
+ const r = await input.runChatOnce(model);
3890
+ return {
3891
+ ok: r.ok,
3892
+ detail: `真实调用 ${model}:${r.ok ? "通了" : `失败(${r.detail})`}`
3893
+ };
3894
+ }
3895
+ case "checkinNow": {
3896
+ const uid = firstAccountUid(m);
3897
+ if (uid === void 0) return {
3898
+ ok: false,
3899
+ detail: "无法验证:连接池里没有可用连接(先加一个 token)"
3900
+ };
3901
+ const v = await m.checkinNow?.(uid);
3902
+ return {
3903
+ ok: v?.ok !== false,
3904
+ detail: `对连接 ${uid} 真实签到:${describe(v)}`
3905
+ };
3906
+ }
3907
+ case "generateLoginUrl": return {
3908
+ ok: true,
3909
+ detail: describe(await m.generateLoginUrl?.())
3910
+ };
3911
+ case "completeLogin": return {
3912
+ ok: true,
3913
+ detail: `无效回调实跑:${describe(await m.completeLogin?.(PROBE_CALLBACK))}(没抛错即通过)`
3914
+ };
3915
+ case "removeLink": {
3916
+ const v = await m.removeLink?.(PROBE_UID);
3917
+ return {
3918
+ ok: v !== false,
3919
+ detail: `不存在的连接实跑:${describe(v)}`
3920
+ };
3921
+ }
3922
+ default: return {
3923
+ ok: true,
3924
+ detail: "未纳入自动实跑"
3925
+ };
3926
+ }
3927
+ } catch (err) {
3928
+ return {
3929
+ ok: false,
3930
+ detail: `实跑抛错(这本身就是一条问题):${err.message}`
3931
+ };
3932
+ }
3933
+ }
3934
+ /** 核心代劳的能力区:模型启用/禁用状态 + 那几个改配置的操作(只报可用性)。 */
3935
+ /** 核心区:模型启用状态 + 「全部启用/禁用」实跑结果。 */
3936
+ async function coreReport(input, modelsProbed, bulk) {
3937
+ const models = input.models;
3938
+ const enabled = models?.filter((m) => m.enabled).length;
3939
+ const disabled = models === void 0 ? void 0 : models.length - (enabled ?? 0);
3940
+ return {
3941
+ models: {
3942
+ total: models?.length ?? null,
3943
+ enabled: enabled ?? 0,
3944
+ disabled: disabled ?? 0,
3945
+ ...models === void 0 ? { note: modelsProbed ? "拿不到模型列表" : "listModels 不可用,无法读启用状态" } : {}
3946
+ },
3947
+ operations: [{
3948
+ key: "models.bulk",
3949
+ label: "模型全部启用 / 全部禁用",
3950
+ ran: bulk.ran,
3951
+ ...bulk.ok === void 0 ? {} : { ok: bulk.ok },
3952
+ detail: bulk.detail
3953
+ }]
3954
+ };
3955
+ }
3956
+ /**
3957
+ * 实跑「全部禁用 → 看列表 → 全部启用」。
3958
+ *
3959
+ * 违规的插件会在这里露馅:它把已禁用的模型从 `listModels` 里过滤掉了,于是全部禁用
3960
+ * 之后核心**看不到任何一个模型** —— 用户点「全部启用」也就再也点不回来了
3961
+ * (路由里没有可用的模型 id 可传)。
3962
+ */
3963
+ async function runBulkRoundTrip(input) {
3964
+ if (input.runBulkToggleRoundTrip === void 0) return {
3965
+ ran: false,
3966
+ detail: "没有模型可禁用"
3967
+ };
3968
+ try {
3969
+ const r = await input.runBulkToggleRoundTrip();
3970
+ return {
3971
+ ran: true,
3972
+ ok: r.ok,
3973
+ detail: r.detail
3974
+ };
3975
+ } catch (err) {
3976
+ return {
3977
+ ran: true,
3978
+ ok: false,
3979
+ detail: `实跑抛错:${err.message}`
3980
+ };
3981
+ }
3982
+ }
3983
+ /** 无副作用成员的实跑。**每个 case 都必须没有副作用** —— 新增成员时照此办理。 */
3984
+ async function runSafeProbe(key, m, input) {
3985
+ switch (key) {
3986
+ case "id":
3987
+ case "name":
3988
+ case "priority":
3989
+ case "icon":
3990
+ case "apiKeyHint": {
3991
+ const value = m[key];
3992
+ if (value === void 0) return {
3993
+ ok: true,
3994
+ detail: "未提供(可选)"
3995
+ };
3996
+ return {
3997
+ ok: true,
3998
+ detail: describe(value)
3999
+ };
4000
+ }
4001
+ case "status": {
4002
+ const r = await probeValue(() => m.status());
4003
+ if (!r.ok) return r;
4004
+ const accounts = accountStates(m.status());
4005
+ return {
4006
+ ok: true,
4007
+ detail: accounts.length === 0 ? "无账号(无账号直连型供应商)" : `${accounts.length} 个账号:${accountTally(accounts)}`
4008
+ };
4009
+ }
4010
+ case "listModels": return await probeValue(() => m.listModels());
4011
+ case "pollLogin": return await probeValue(() => m.pollLogin?.());
4012
+ default: return {
4013
+ ok: true,
4014
+ detail: "未纳入自动探测"
4015
+ };
4016
+ }
4017
+ }
4018
+ //#endregion
3423
4019
  //#region src/suppliers/registry.ts
3424
4020
  function writeJson$1(res, status, body) {
3425
4021
  const payload = JSON.stringify(body);
@@ -3496,6 +4092,106 @@ function supplierRoutes(base, loaded, store, router) {
3496
4092
  });
3497
4093
  }
3498
4094
  });
4095
+ routes.push({
4096
+ kind: "exact",
4097
+ path: `${p}/enabled`,
4098
+ handler: async (req, res) => {
4099
+ let body;
4100
+ try {
4101
+ body = JSON.parse(await readBody$1(req));
4102
+ } catch {
4103
+ writeJson$1(res, 400, {
4104
+ ok: false,
4105
+ error: "invalid JSON body"
4106
+ });
4107
+ return;
4108
+ }
4109
+ if (typeof body.enabled !== "boolean") {
4110
+ writeJson$1(res, 400, {
4111
+ ok: false,
4112
+ error: "enabled must be a boolean"
4113
+ });
4114
+ return;
4115
+ }
4116
+ store.setEnabled(s.id, body.enabled);
4117
+ router.invalidateModels(s.id);
4118
+ writeJson$1(res, 200, {
4119
+ ok: true,
4120
+ id: s.id,
4121
+ enabled: router.isEnabled(s.id)
4122
+ });
4123
+ }
4124
+ });
4125
+ routes.push({
4126
+ kind: "exact",
4127
+ path: `${p}/probe`,
4128
+ handler: async (req, res) => {
4129
+ if (req.method !== "POST") {
4130
+ writeJson$1(res, 405, {
4131
+ ok: false,
4132
+ error: "probe requires POST"
4133
+ });
4134
+ return;
4135
+ }
4136
+ try {
4137
+ let pick = {};
4138
+ const raw = (await readBody$1(req)).trim();
4139
+ if (raw !== "") {
4140
+ const body = JSON.parse(raw);
4141
+ pick = {
4142
+ ...typeof body.model === "string" ? { model: body.model } : {},
4143
+ ...typeof body.uid === "string" ? { uid: body.uid } : {}
4144
+ };
4145
+ }
4146
+ writeJson$1(res, 200, {
4147
+ ok: true,
4148
+ report: await probeSupplier(loaded, {
4149
+ models: await router.modelsOf(s.id).catch(() => void 0),
4150
+ runBulkToggleRoundTrip: async () => {
4151
+ const original = [...store.get(s.id).disabled];
4152
+ const ids = (await router.modelsOf(s.id)).map((mm) => mm.id);
4153
+ if (ids.length === 0) return {
4154
+ ok: true,
4155
+ detail: "该供应商没有模型,跳过"
4156
+ };
4157
+ try {
4158
+ store.setAllModelsEnabled(s.id, false, ids);
4159
+ router.invalidateModels(s.id);
4160
+ const after = await router.modelsOf(s.id, true);
4161
+ const hidden = after.filter((mm) => !mm.enabled).length;
4162
+ const missing = ids.length - after.length;
4163
+ if (hidden !== ids.length || missing !== 0) return {
4164
+ ok: false,
4165
+ detail: `全部禁用后只剩 ${after.length}/${ids.length} 个 —— 插件在 listModels 里藏了已禁用的模型(启用状态由核心合并)`
4166
+ };
4167
+ return {
4168
+ ok: true,
4169
+ detail: `全部禁用后 ${after.length} 个都在,再启用可恢复`
4170
+ };
4171
+ } finally {
4172
+ store.setAllModelsEnabled(s.id, true, ids);
4173
+ for (const id of original) store.setAllModelsEnabled(s.id, false, [id]);
4174
+ router.invalidateModels(s.id);
4175
+ await router.modelsOf(s.id, true);
4176
+ }
4177
+ },
4178
+ runChatOnce: async (model) => {
4179
+ const r = await router.testModel(s.id, model, pick.uid);
4180
+ return {
4181
+ ok: r.ok,
4182
+ detail: r.ok ? "通了" : r.error ?? "上游没给原因"
4183
+ };
4184
+ }
4185
+ })
4186
+ });
4187
+ } catch (err) {
4188
+ writeJson$1(res, 500, {
4189
+ ok: false,
4190
+ error: err.message
4191
+ });
4192
+ }
4193
+ }
4194
+ });
3499
4195
  routes.push({
3500
4196
  kind: "exact",
3501
4197
  path: `${p}/alias`,
@@ -3629,7 +4325,8 @@ function supplierRoutes(base, loaded, store, router) {
3629
4325
  path: `${p}/models/test`,
3630
4326
  handler: async (req, res) => {
3631
4327
  const body = JSON.parse(await readBody$1(req));
3632
- const result = await router.testModel(s.id, body.id ?? "");
4328
+ const onlyUid = typeof body.uid === "string" && body.uid !== "" ? body.uid : void 0;
4329
+ const result = await router.testModel(s.id, body.id ?? "", onlyUid);
3633
4330
  writeJson$1(res, result.ok ? 200 : 400, result);
3634
4331
  }
3635
4332
  });
@@ -3874,6 +4571,47 @@ function dshHome() {
3874
4571
  return resolve(value);
3875
4572
  }
3876
4573
  //#endregion
4574
+ //#region src/host-base-url.ts
4575
+ /**
4576
+ * 网关端点地址(`http://127.0.0.1:<宿主端口>/v1`)的解析。
4577
+ *
4578
+ * ## 为什么端口不能写死
4579
+ *
4580
+ * dsh-router **自己不开端口**:`/v1/*` 是注册在**宿主** web server 上的路由
4581
+ * (见 `index.ts` 的 `ctx.webServer.register`)。所以端点地址里的端口由启动通道
4582
+ * 决定,而不是插件决定 —— `dsh web` 默认 3080,桌面端官方通道、以及配置
4583
+ * `port: 0`(让 OS 分配)时都**不是** 3080。之前 adapter 把
4584
+ * `http://localhost:3080/v1` 写死在构造参数里,于是非 3080 的宿主上 Router
4585
+ * 的每一轮对话都是 `dsh-router upstream call failed: fetch failed`(issue #9)。
4586
+ *
4587
+ * ## 为什么返回函数而不是字符串
4588
+ *
4589
+ * `webServer.port` 是**监听之后**才有值的 getter(listen 前是 `undefined`),
4590
+ * 且宿主重启/换端口后它会变。所以这里返回函数、**每次请求现算**,不在注册时
4591
+ * 算一次 —— 注册发生在启动早期,那时算只会把「拿不到端口」固化成常量。
4592
+ *
4593
+ * ## 拿不到端口时为什么抛,而不是回退 3080
4594
+ *
4595
+ * 回退等于把同一个 bug 换种写法:端口读不到时宿主的路由也没在服务,回退到 3080
4596
+ * 只会重新得到一句无从查证的 `fetch failed`。这里抛明确的话,让它出现在
4597
+ * `LlmError` 消息里(adapter 在 fetch 同一个 try 里调它),报错自带原因。
4598
+ */
4599
+ /** 回环地址:宿主即使监听 `0.0.0.0`(LAN 模式),走 127.0.0.1 也通。 */
4600
+ const LOOPBACK_HOST = "127.0.0.1";
4601
+ /**
4602
+ * 造一个「每次调用现读宿主端口」的端点解析器。
4603
+ *
4604
+ * @param readPort 读宿主监听端口(`webServer.port`)。取不到时抛错而不是猜。
4605
+ * @param path 端点路径前缀,默认 `/v1`。
4606
+ */
4607
+ function loopbackBaseURL(readPort, path = "/v1") {
4608
+ return () => {
4609
+ const port = readPort();
4610
+ if (port === void 0) throw new Error("dsh-router: 宿主 webServer 端口未就绪(webServer.port 读不到),无法定位 /v1 端点");
4611
+ return `http://${LOOPBACK_HOST}:${String(port)}${path}`;
4612
+ };
4613
+ }
4614
+ //#endregion
3877
4615
  //#region src/host-version.ts
3878
4616
  /**
3879
4617
  * 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
@@ -4183,6 +4921,15 @@ function apply(rawContext) {
4183
4921
  const extStore = new ExtStore(stateFile);
4184
4922
  ctx.provide("router.extStore", extStore);
4185
4923
  const registerLoaded = (loaded) => {
4924
+ const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id);
4925
+ if (existing !== void 0) {
4926
+ if (!supplierWins(loaded.source, existing.source)) {
4927
+ log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`);
4928
+ return;
4929
+ }
4930
+ log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`);
4931
+ unregisterSupplier(loaded.supplier.id);
4932
+ }
4186
4933
  loadedSuppliers.push(loaded);
4187
4934
  router.add(loaded.supplier);
4188
4935
  registerSupplierRoutes(loaded);
@@ -4193,22 +4940,24 @@ function apply(rawContext) {
4193
4940
  for (const [sid, factory] of Object.entries(suppliers)) {
4194
4941
  if (externalSupplierIds.includes(sid)) continue;
4195
4942
  try {
4196
- const loaded = wrapModule(factory({
4943
+ const module = factory({
4197
4944
  dataDir,
4198
4945
  log,
4199
4946
  store,
4200
4947
  credentials
4201
- }), {
4948
+ });
4949
+ const kind = factory.source === "builtin" ? "builtin" : "external";
4950
+ const loaded = wrapModule(module, {
4202
4951
  dataDir,
4203
4952
  log,
4204
4953
  store,
4205
4954
  credentials
4206
- }, `service router.suppliers.${sid}`);
4955
+ }, `service router.suppliers.${sid}`, kind);
4207
4956
  registerLoaded(loaded);
4208
4957
  externalSupplierIds.push(loaded.supplier.id);
4209
- log(`external supplier loaded: ${sid}`);
4958
+ log(`${kind} supplier loaded: ${sid}`);
4210
4959
  } catch (err) {
4211
- ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${err.message}`);
4960
+ ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${err.message}`);
4212
4961
  }
4213
4962
  }
4214
4963
  };
@@ -4227,10 +4976,8 @@ function apply(rawContext) {
4227
4976
  });
4228
4977
  (async () => {
4229
4978
  try {
4230
- const userDir = join(profileDirOf(ctx.baseUrl), "suppliers");
4231
4979
  const { suppliers, errors } = await loadSuppliers({
4232
- builtinDir: join(import.meta.dirname, "suppliers"),
4233
- userDir,
4980
+ userDir: join(profileDirOf(ctx.baseUrl), "suppliers"),
4234
4981
  dataDir,
4235
4982
  store,
4236
4983
  credentials,
@@ -4312,7 +5059,8 @@ function apply(rawContext) {
4312
5059
  icon: loaded?.supplier.icon,
4313
5060
  apiKeyHint: loaded?.supplier.apiKeyHint,
4314
5061
  capabilities: loaded ? [...loaded.capabilities] : [],
4315
- source: loaded?.source ?? "external"
5062
+ source: loaded?.source ?? "external",
5063
+ enabled: s.enabled
4316
5064
  };
4317
5065
  })
4318
5066
  });
@@ -4568,6 +5316,7 @@ function apply(rawContext) {
4568
5316
  ...e.icon !== void 0 ? { icon: e.icon } : {},
4569
5317
  enabled: extStore.isEnabled(e.id),
4570
5318
  ready: st?.ready === true,
5319
+ ...raw.source === "builtin" ? { source: "builtin" } : {},
4571
5320
  ...st?.detail !== void 0 ? { detail: st.detail } : {}
4572
5321
  };
4573
5322
  }).filter((e) => e !== void 0)
@@ -4651,13 +5400,13 @@ function apply(rawContext) {
4651
5400
  });
4652
5401
  log(`Router settings section registered (${settingsNs})`);
4653
5402
  });
4654
- disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
5403
+ disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(loopbackBaseURL(() => ctx.webServer?.port), { comboModels: async () => (await router.combos()).map((c) => {
4655
5404
  const w = router.comboContextWindow(c);
4656
5405
  return {
4657
5406
  id: c.name,
4658
5407
  ...w !== void 0 ? { contextWindow: w } : {}
4659
5408
  };
4660
- }) }, () => ctx.get("attachments"))));
5409
+ }) }, () => ctx.get("attachments"), void 0, (msg) => ctx.logger.warn(`[dsh-router] ${msg}`))));
4661
5410
  log("llm provider (Router) + discovery + adapter registered ok");
4662
5411
  (async () => {
4663
5412
  for (const c of await router.combos()) for (const ref of c.models) {