dsh-router-core 0.4.3 → 0.4.5

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 (145) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/cordis.patch.yml +0 -0
  4. package/docs/ext.md +0 -0
  5. package/docs/pool-sticky-block.md +59 -1
  6. package/docs/screenshot.png +0 -0
  7. package/docs/suppliers.md +20 -1
  8. package/lib/client-registry.js +24 -14
  9. package/lib/client.js +24 -14
  10. package/lib/ext-prompt/index.js +2 -2
  11. package/lib/ext-prompt/locale/en.json +0 -0
  12. package/lib/ext-prompt/locale/zh.json +0 -0
  13. package/lib/ext-prompt/package.json +0 -0
  14. package/lib/ext-test/locale/en.json +0 -0
  15. package/lib/ext-test/locale/zh.json +0 -0
  16. package/lib/ext-test/package.json +0 -0
  17. package/lib/index.js +106 -17
  18. package/lib/suppliers/nvidia/locale/en.json +0 -0
  19. package/lib/suppliers/nvidia/locale/zh.json +0 -0
  20. package/lib/suppliers/nvidia/package.json +0 -0
  21. package/lib/suppliers/opencode/locale/en.json +0 -0
  22. package/lib/suppliers/opencode/locale/zh.json +0 -0
  23. package/lib/suppliers/opencode/package.json +0 -0
  24. package/lib/suppliers/openrouter/locale/en.json +0 -0
  25. package/lib/suppliers/openrouter/locale/zh.json +0 -0
  26. package/lib/suppliers/openrouter/package.json +0 -0
  27. package/lib/types/ext-prompt/content.d.ts +10 -3
  28. package/lib/types/router/account-pool.d.ts +23 -1
  29. package/lib/types/router/index.d.ts +25 -0
  30. package/lib/types/router/types.d.ts +9 -1
  31. package/lib/types/shared.d.ts +6 -0
  32. package/package.json +1 -1
  33. package/src/client/CheckinCard.tsx +0 -0
  34. package/src/client/CombosTab.tsx +0 -0
  35. package/src/client/EndpointTab.tsx +0 -0
  36. package/src/client/ExtDetail.tsx +0 -0
  37. package/src/client/ExtTab.tsx +0 -0
  38. package/src/client/ExtTestPanel.tsx +0 -0
  39. package/src/client/LastHitDock.tsx +0 -0
  40. package/src/client/Modal.tsx +0 -0
  41. package/src/client/RouterComponentsSection.tsx +0 -0
  42. package/src/client/RouterView.tsx +0 -0
  43. package/src/client/StatsTab.tsx +0 -0
  44. package/src/client/SupplierDetail.tsx +1 -5
  45. package/src/client/account-badge.test.ts +37 -0
  46. package/src/client/account-badge.ts +24 -0
  47. package/src/client/comp-button-scope.test.ts +0 -0
  48. package/src/client/comp-panel-layout.test.ts +0 -0
  49. package/src/client/env.d.ts +0 -0
  50. package/src/client/ext-panels.test.ts +0 -0
  51. package/src/client/ext-panels.ts +0 -0
  52. package/src/client/index.tsx +0 -0
  53. package/src/client/last-hit-store.ts +0 -0
  54. package/src/client/model-choice.test.ts +0 -0
  55. package/src/client/model-choice.ts +0 -0
  56. package/src/client/model-hint-copy.test.ts +0 -0
  57. package/src/client/model-hint-copy.ts +0 -0
  58. package/src/client/router-components.test.ts +0 -0
  59. package/src/client/router-components.ts +0 -0
  60. package/src/client/router.css +0 -0
  61. package/src/client/settings-nav-icon.ts +0 -0
  62. package/src/client/settings-section.tsx +0 -0
  63. package/src/client/sidebar-entry.ts +0 -0
  64. package/src/client/supplier-visibility.test.ts +0 -0
  65. package/src/client/supplier-visibility.ts +0 -0
  66. package/src/client/workspace-controller.ts +0 -0
  67. package/src/client/workspace-mount.tsx +0 -0
  68. package/src/credential-store.ts +0 -0
  69. package/src/data-dir.test.ts +0 -0
  70. package/src/data-dir.ts +0 -0
  71. package/src/ext/contract.ts +0 -0
  72. package/src/ext/store.test.ts +0 -0
  73. package/src/ext/store.ts +0 -0
  74. package/src/ext-prompt/assembly.test.ts +0 -0
  75. package/src/ext-prompt/content.ts +14 -7
  76. package/src/ext-prompt/ext-prompt.test.ts +0 -0
  77. package/src/ext-prompt/index.ts +0 -0
  78. package/src/ext-prompt/locale/en.json +0 -0
  79. package/src/ext-prompt/locale/zh.json +0 -0
  80. package/src/ext-prompt/logo.svg +0 -0
  81. package/src/ext-prompt/logo.ts +0 -0
  82. package/src/ext-prompt/mount.test.ts +0 -0
  83. package/src/ext-prompt/mount.ts +0 -0
  84. package/src/ext-prompt/plugin.ts +0 -0
  85. package/src/ext-prompt/render.test.ts +0 -0
  86. package/src/ext-prompt/render.ts +0 -0
  87. package/src/ext-prompt/row.json +0 -0
  88. package/src/ext-route.test.ts +0 -0
  89. package/src/ext-test/ext-test.test.ts +0 -0
  90. package/src/ext-test/index.ts +0 -0
  91. package/src/ext-test/locale/en.json +0 -0
  92. package/src/ext-test/locale/zh.json +0 -0
  93. package/src/ext-test/plugin.ts +0 -0
  94. package/src/ext-test/row.json +0 -0
  95. package/src/host-base-url.test.ts +0 -0
  96. package/src/host-base-url.ts +0 -0
  97. package/src/host-version.test.ts +0 -0
  98. package/src/host-version.ts +0 -0
  99. package/src/index.ts +12 -1
  100. package/src/keys.ts +0 -0
  101. package/src/llm/adapter.test.ts +0 -0
  102. package/src/llm/adapter.ts +0 -0
  103. package/src/router/account-pool.test.ts +74 -0
  104. package/src/router/account-pool.ts +37 -5
  105. package/src/router/combo-change.test.ts +125 -0
  106. package/src/router/disconnect-leak.test.ts +0 -0
  107. package/src/router/first-byte-budget.test.ts +0 -0
  108. package/src/router/index.ts +43 -3
  109. package/src/router/models-cache.test.ts +0 -0
  110. package/src/router/precise-dispatch.test.ts +0 -0
  111. package/src/router/prefix-affinity.ts +0 -0
  112. package/src/router/supplier-switch.test.ts +91 -0
  113. package/src/router/types.ts +9 -1
  114. package/src/router/usage-store.ts +0 -0
  115. package/src/router/usage-tokens.ts +0 -0
  116. package/src/router/usage.test.ts +0 -0
  117. package/src/shared.ts +6 -0
  118. package/src/supplier-config.test.ts +0 -0
  119. package/src/supplier-config.ts +0 -0
  120. package/src/suppliers/contract.ts +0 -0
  121. package/src/suppliers/http-state.test.ts +0 -0
  122. package/src/suppliers/http-state.ts +0 -0
  123. package/src/suppliers/loader.test.ts +53 -0
  124. package/src/suppliers/loader.ts +0 -0
  125. package/src/suppliers/nvidia/index.ts +0 -0
  126. package/src/suppliers/nvidia/locale/en.json +0 -0
  127. package/src/suppliers/nvidia/locale/zh.json +0 -0
  128. package/src/suppliers/nvidia/plugin.ts +0 -0
  129. package/src/suppliers/nvidia/row.json +0 -0
  130. package/src/suppliers/opencode/index.ts +0 -0
  131. package/src/suppliers/opencode/locale/en.json +0 -0
  132. package/src/suppliers/opencode/locale/zh.json +0 -0
  133. package/src/suppliers/opencode/plugin.test.ts +0 -0
  134. package/src/suppliers/opencode/plugin.ts +0 -0
  135. package/src/suppliers/opencode/row.json +0 -0
  136. package/src/suppliers/openrouter/index.ts +0 -0
  137. package/src/suppliers/openrouter/locale/en.json +0 -0
  138. package/src/suppliers/openrouter/locale/zh.json +0 -0
  139. package/src/suppliers/openrouter/plugin.ts +0 -0
  140. package/src/suppliers/openrouter/row.json +0 -0
  141. package/src/suppliers/probe.test.ts +0 -0
  142. package/src/suppliers/probe.ts +0 -0
  143. package/src/suppliers/registry.test.ts +56 -0
  144. package/src/suppliers/registry.ts +40 -10
  145. package/src/types.d.ts +0 -0
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/cordis.patch.yml CHANGED
File without changes
package/docs/ext.md CHANGED
File without changes
@@ -206,7 +206,8 @@ pick():
206
206
  前置,二者可叠加(亲和负责选号、块负责驻留)。
207
207
  - **积分感知换挡**:`credits` 已持久化,可做「积分跌破阈值才换挡」。
208
208
  但 `credits` 只存最后一次值不带时间戳,需先确认 `status()` 刷新频率,
209
- 否则触发失真。当前未启用。
209
+ 否则触发失真。**「积分归零」这一档已于 2026-10-14 落地**(见 §12),
210
+ 「跌破阈值(还有余额但快用完)」这一档仍未启用。
210
211
  - **TTL 仍未知**:87.5% 是保证下限,实际可能更高。要精确 N 需反推 TTL
211
212
  (同号相邻请求间隔 vs 命中率拟合驱逐拐点)。
212
213
 
@@ -413,3 +414,60 @@ Lead 与 T1 撞同一个号,T2 独占)。改为**占用感知分配**(`allocSess
413
414
  - **每会话一份样本 → 内存随会话数增长**:`samples` 按会话分叉,长跑多会话
414
415
  场景条目变多。当前无上限(随进程存亡),量大时再加 LRU(对齐 `AFFINITY_MAX`
415
416
  的做法)。
417
+
418
+ ## 12. 积分用完的号退出选号(2026-10-14 落地)
419
+
420
+ ### 12.1 症状
421
+
422
+ `test` 组合里一个 traework 账号积分归零,请求报错但**没有换到还有额度的号**;
423
+ 手动测这个连接,报的是「连接 "4135677434275728" 不存在(可能已被删除)」——
424
+ 而它明明在面板的账号列表里。
425
+
426
+ ### 12.2 根因(两处,各修一半)
427
+
428
+ 1. **插件报 `ok`**:traework 的 `pool.list()` 只按凭证判状态——`accessToken === ''`
429
+ 才是 `session_dead`,其余一律 `ok`。积分归零没被解读成 `quota`,核心**根本
430
+ 不知道**这个号没额度。
431
+
432
+ 2. **核心不读插件报的状态**:`pick()`/`healthy()` 只查自己的冷却表,插件报的
433
+ `state` 从不参与选号。所以就算插件报 `quota`,核心照样会选它。
434
+
435
+ 后果:积分归零的号一直"看起来健康",被选中的那次请求必然失败;而 traework 的
436
+ **积分耗尽失败是流式失败**(HTTP 200 + 流内 `event:error code=4008`),响应已经
437
+ 提交,这次请求**救不回来**——只能等失败后 `onLateFailure` 记账,下个请求才换号。
438
+ 即用户每次都要先白撞一次。
439
+
440
+ ### 12.3 修法
441
+
442
+ - **插件侧**(`dsh-router-traework` 的 `pool.list()`):`credits === 0` 报 `quota`。
443
+ 严格区分 `CREDITS_UNKNOWN(-1)`——那是「还没拉到」,不是「用完了」。
444
+ - **核心侧**(`account-pool.servableByReport`):`quota` / `session_dead` 的号
445
+ 不进候选。只认这两个**账号级、要靠外部动作恢复**的事实;`transport`/`unknown`/
446
+ `unavailable` 是某次采样的瞬时观察,拿它排除等于「一次抖动就踢号」,那些仍走
447
+ 冷却单元。
448
+ - **诊断侧**(`router/index.ts`):指定连接测试失败后重试时,不再把上游真因覆盖成
449
+ 「连接不存在」。`tried` 里已有该 uid = 它刚试过并失败,保留 `trace.lastError`。
450
+
451
+ **核心不自己比 `credits`**:openrouter / nvidia 没有积分概念,报的就是
452
+ `credits: 0, state: 'ok'`,核心若拿 `credits === 0` 当「用完」会把它们整池排除。
453
+ 是否"用完"由**插件解读**成 `state` 后报上来(契约:插件报「现在怎么了」,核心决定
454
+ 「怎么办」)。
455
+
456
+ ### 12.4 回归测试
457
+
458
+ - `account-pool.test.ts`:`quota`/`session_dead` 不进选号、全池没额度时的
459
+ `lastWhy` 不谎称「都在冷却」、充值后立刻回候选、`credits: 0 + state: 'ok'`
460
+ 的号不受影响、瞬时状态不排除号、亲和绑定撞上 `quota` 时换号。
461
+ - `supplier-switch.test.ts`:报 `quota` 的号请求直接落到有额度的号;指定连接
462
+ 失败报上游真因、不说成「不存在」。
463
+ - `loader.test.ts`:插件报的 `quota` 原样到达核心。
464
+ - traework `pool.test.ts`:0 报 `quota`、`-1` 仍报 `ok`、`session_dead` 优先。
465
+ - `account-badge.test.ts`:面板不把被排除的号显示成「健康」。
466
+
467
+ ### 12.5 天花板(ponytail)
468
+
469
+ - **仍是「撞一次才换」的兜底**:`refreshingCredits` 有 10 分钟 TTL,积分在实际
470
+ 用完到插件报 `quota` 之间有个窗口;而且本次请求已提交,补救只对后续请求生效。
471
+ 要做到"用完前就换"需要**余额阈值换挡**(§8 仍标未启用)。
472
+ - **不排除「余额快用完」**:只处理归零。阈值需要可信的刷新时间戳,`credits`
473
+ 目前只存最后一次值。
File without changes
package/docs/suppliers.md CHANGED
@@ -74,7 +74,7 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
74
74
  |---|---|
75
75
  | `ok` | 正常 |
76
76
  | `rate_limit` | 限流(429 类) |
77
- | `quota` | 额度/权益不足 |
77
+ | `quota` | 额度/权益不足(**积分/额度用完也报它**,见下) |
78
78
  | `session_dead` | 凭证失效/凭证被上游拒(403 类,含风控误伤) |
79
79
  | `unavailable` | 上游不可用(404 / 服务下线) |
80
80
  | `transport` | 网络/连接层失败(没拿到 HTTP 状态) |
@@ -82,6 +82,25 @@ js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给
82
82
  | `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
83
83
  | `bad_request` | **请求本身不合法,被上游拒**(参数错/图片认不出/tool_call 配对断裂;不是账号的失败) |
84
84
 
85
+ **`status()` 里报 `quota` / `session_dead` 会影响选号**:这两个是「要靠外部动作
86
+ 才能恢复」的账号级事实(充值、重新登录),核心**不把它们选进候选**
87
+ (`account-pool.servableByReport`)。所以有积分概念的 js 应在积分归零时直接报
88
+ `quota`,不要报 `ok`:
89
+
90
+ ```ts
91
+ // traework 的做法(src/api/pool.ts list()):0 是真值,就是「用完了」
92
+ state: accessToken === '' ? 'session_dead' : credits === 0 ? 'quota' : 'ok'
93
+ ```
94
+
95
+ **但 `credits === 0` 不等于「没额度」——不要用积分数字代替状态判断。** 有些
96
+ 供应商(openrouter / nvidia)压根没有积分概念,它们的 `0` 是「这个字段不适用」,
97
+ 报的是 `credits: 0, state: 'ok'`,核心**不会**因此排除它们(核心只看 `state`,
98
+ 不自己比 `credits`)。有积分概念的供应商才做上面那个映射。**`-1` 更不是 0**:
99
+ 那是「还没拉到」,此刻对这个号一无所知,必须保持 `ok`。
100
+
101
+ 其它状态不参与选号(只用于失败后的处置),报瞬时抖动为 `quota` 一样会让好号
102
+ 白躺 10 分钟。
103
+
85
104
  **`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里会
86
105
  把**这个号 × 这个模型**冷掉——组合里每有一个别人家的模型,js 就会对每个账号
87
106
  都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
@@ -17,6 +17,26 @@ window.__ModuleLoader__.load({
17
17
  /** Same-origin API base the client panel fetches (host half serves it). */
18
18
  var ROUTER_API_BASE = "/router/api";
19
19
  //#endregion
20
+ //#region src/client/account-badge.ts
21
+ function statusBadge(account) {
22
+ if (account.cooling) return {
23
+ text: "冷却中",
24
+ cls: "dshr-badge-warn"
25
+ };
26
+ if (account.state === "quota") return {
27
+ text: "额度用完",
28
+ cls: "dshr-badge-warn"
29
+ };
30
+ if (account.state === "session_dead") return {
31
+ text: "凭证失效",
32
+ cls: "dshr-badge-warn"
33
+ };
34
+ return {
35
+ text: "健康",
36
+ cls: "dshr-badge-ok"
37
+ };
38
+ }
39
+ //#endregion
20
40
  //#region src/client/Modal.tsx
21
41
  /**
22
42
  * 通用弹窗:遮罩 + 居中对话框,ESC / 点遮罩关闭。9router 风格。
@@ -116,16 +136,6 @@ window.__ModuleLoader__.load({
116
136
  if (minutes < 60) return `剩余 ${minutes} 分钟`;
117
137
  return `剩余 ${Math.ceil(minutes / 60)} 小时`;
118
138
  }
119
- function statusBadge(account) {
120
- if (account.cooling) return {
121
- text: "冷却中",
122
- cls: "dshr-badge-warn"
123
- };
124
- return {
125
- text: "健康",
126
- cls: "dshr-badge-ok"
127
- };
128
- }
129
139
  function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRefresh }) {
130
140
  const hasCap = (name) => supplier.capabilities?.includes(name) ?? false;
131
141
  const canLogin = hasCap("generateLoginUrl") || hasCap("addApiKey");
@@ -4515,7 +4525,7 @@ window.__ModuleLoader__.load({
4515
4525
  };
4516
4526
  }
4517
4527
  //#endregion
4518
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
4528
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/typeof.js
4519
4529
  function _typeof(o) {
4520
4530
  "@babel/helpers - typeof";
4521
4531
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -4525,7 +4535,7 @@ window.__ModuleLoader__.load({
4525
4535
  }, _typeof(o);
4526
4536
  }
4527
4537
  //#endregion
4528
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
4538
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPrimitive.js
4529
4539
  function toPrimitive(t, r) {
4530
4540
  if ("object" != _typeof(t) || !t) return t;
4531
4541
  var e = t[Symbol.toPrimitive];
@@ -4537,13 +4547,13 @@ window.__ModuleLoader__.load({
4537
4547
  return ("string" === r ? String : Number)(t);
4538
4548
  }
4539
4549
  //#endregion
4540
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
4550
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPropertyKey.js
4541
4551
  function toPropertyKey(t) {
4542
4552
  var i = toPrimitive(t, "string");
4543
4553
  return "symbol" == _typeof(i) ? i : i + "";
4544
4554
  }
4545
4555
  //#endregion
4546
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
4556
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/defineProperty.js
4547
4557
  function _defineProperty(e, r, t) {
4548
4558
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
4549
4559
  value: t,
package/lib/client.js CHANGED
@@ -17,6 +17,26 @@ window.__ModuleLoader__.load({
17
17
  /** Same-origin API base the client panel fetches (host half serves it). */
18
18
  var ROUTER_API_BASE = "/router/api";
19
19
  //#endregion
20
+ //#region src/client/account-badge.ts
21
+ function statusBadge(account) {
22
+ if (account.cooling) return {
23
+ text: "冷却中",
24
+ cls: "dshr-badge-warn"
25
+ };
26
+ if (account.state === "quota") return {
27
+ text: "额度用完",
28
+ cls: "dshr-badge-warn"
29
+ };
30
+ if (account.state === "session_dead") return {
31
+ text: "凭证失效",
32
+ cls: "dshr-badge-warn"
33
+ };
34
+ return {
35
+ text: "健康",
36
+ cls: "dshr-badge-ok"
37
+ };
38
+ }
39
+ //#endregion
20
40
  //#region src/client/Modal.tsx
21
41
  /**
22
42
  * 通用弹窗:遮罩 + 居中对话框,ESC / 点遮罩关闭。9router 风格。
@@ -116,16 +136,6 @@ window.__ModuleLoader__.load({
116
136
  if (minutes < 60) return `剩余 ${minutes} 分钟`;
117
137
  return `剩余 ${Math.ceil(minutes / 60)} 小时`;
118
138
  }
119
- function statusBadge(account) {
120
- if (account.cooling) return {
121
- text: "冷却中",
122
- cls: "dshr-badge-warn"
123
- };
124
- return {
125
- text: "健康",
126
- cls: "dshr-badge-ok"
127
- };
128
- }
129
139
  function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRefresh }) {
130
140
  const hasCap = (name) => supplier.capabilities?.includes(name) ?? false;
131
141
  const canLogin = hasCap("generateLoginUrl") || hasCap("addApiKey");
@@ -4515,7 +4525,7 @@ window.__ModuleLoader__.load({
4515
4525
  };
4516
4526
  }
4517
4527
  //#endregion
4518
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
4528
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/typeof.js
4519
4529
  function _typeof(o) {
4520
4530
  "@babel/helpers - typeof";
4521
4531
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -4525,7 +4535,7 @@ window.__ModuleLoader__.load({
4525
4535
  }, _typeof(o);
4526
4536
  }
4527
4537
  //#endregion
4528
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
4538
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPrimitive.js
4529
4539
  function toPrimitive(t, r) {
4530
4540
  if ("object" != _typeof(t) || !t) return t;
4531
4541
  var e = t[Symbol.toPrimitive];
@@ -4537,13 +4547,13 @@ window.__ModuleLoader__.load({
4537
4547
  return ("string" === r ? String : Number)(t);
4538
4548
  }
4539
4549
  //#endregion
4540
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
4550
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/toPropertyKey.js
4541
4551
  function toPropertyKey(t) {
4542
4552
  var i = toPrimitive(t, "string");
4543
4553
  return "symbol" == _typeof(i) ? i : i + "";
4544
4554
  }
4545
4555
  //#endregion
4546
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
4556
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/defineProperty.js
4547
4557
  function _defineProperty(e, r, t) {
4548
4558
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
4549
4559
  value: t,
@@ -29,13 +29,13 @@ const PROMPT_CATEGORIES = [
29
29
  id: "closure",
30
30
  title: "全局收口",
31
31
  defaultOn: true,
32
- body: "收口(与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位;④前三问都空时处置一件存量(删/并/归档)。做完那块区域该比动手前更整或更少。"
32
+ body: "收口(只在任务完成时做,与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位。做完那块区域该比动手前更整或更少。"
33
33
  },
34
34
  {
35
35
  id: "hardening",
36
36
  title: "固化",
37
37
  defaultOn: true,
38
- body: "固化:每次造成用户可见损失的失败,当天固化为可自动重放的检查——会变红(注入违规必须失败)、可重放(一条命令)、归属清晰(失败信息指向违规处);面向他人的检查只警告不阻断。不固化的教训等于决定再犯一次。"
38
+ body: "固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。"
39
39
  },
40
40
  {
41
41
  id: "verdifiable",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/lib/index.js CHANGED
@@ -275,6 +275,24 @@ var AccountPool = class {
275
275
  return c.until <= now;
276
276
  }
277
277
  /**
278
+ * 插件报的「现在状态」是否意味着这个号此刻不该被选中。
279
+ *
280
+ * 只认两种**账号级持久事实**(要靠外部动作才能恢复,不是抖动):
281
+ * - `quota` —— 额度/积分用完,充值才能恢复(traework 积分归零时报它)
282
+ * - `session_dead` —— 凭证失效,重新登录才能恢复
283
+ *
284
+ * 其余一律不看:`transport`/`unknown`/`unavailable` 是插件某一次采样的瞬时
285
+ * 观察,拿它排除等于「一次抖动就踢号」,而且没有冷却计时器兜底;`rate_limit`
286
+ * 的指数退避由核心的冷却单元管,比状态采样准。
287
+ *
288
+ * 这是「插件只报现在怎么了、核心决定怎么办」的分工落地:插件报出它观察到
289
+ * 的事实,核心决定此刻不选它。恢复同样自动——充值/重登后插件报回 `ok`,
290
+ * 该号立刻回到候选(没有额外状态要清)。
291
+ */
292
+ servableByReport(a) {
293
+ return a?.state !== "quota" && a?.state !== "session_dead";
294
+ }
295
+ /**
278
296
  * 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
279
297
  *
280
298
  * 旧的两策略(fallback 取第一个 / round-robin 轮转)都删了:前者让所有
@@ -289,11 +307,17 @@ var AccountPool = class {
289
307
  * "策略",是无会话身份时的替身。
290
308
  * 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
291
309
  * 降权该号(缓存无效,可恢复)。
310
+ * 5. 插件报的 `quota`/`session_dead`(额度用完 / 凭证失效)→ **直接不选它**:
311
+ * 这类失败要靠充值/重登才能恢复,选它必是白撞一次(还有的供应商失败在
312
+ * 流已提交之后,这次请求根本救不回来)。见 `servableByReport`。
292
313
  *
293
314
  * 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
294
315
  * (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
295
316
  *
296
- * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
317
+ * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)。
318
+ * `state` 会被读:`quota`/`session_dead` 的号不进候选(见 `servableByReport`)。
319
+ * `credits` **不读** —— 不同供应商的 0 含义不同(openrouter/nvidia 的 0 是
320
+ * 「没有积分这个概念」),是否已用完由插件解读成 `state` 后报上来。
297
321
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
298
322
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
299
323
  * @param messages 请求体的 messages(算亲和指纹的内容兜底)
@@ -303,9 +327,9 @@ var AccountPool = class {
303
327
  pick(accounts, poolOrder, modelId, messages, session) {
304
328
  const now = Date.now();
305
329
  const byUid = new Map(accounts.map((a) => [a.uid, a]));
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));
330
+ 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) && this.servableByReport(byUid.get(uid)));
307
331
  if (healthy.length === 0) {
308
- this.lastWhy = "全池无健康号(都在冷却)";
332
+ this.lastWhy = "全池无健康号(都在冷却,或插件报的额度/凭证已不可用)";
309
333
  return;
310
334
  }
311
335
  const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now, session));
@@ -320,8 +344,12 @@ var AccountPool = class {
320
344
  if (fp === "") why.push("无指纹(拿不到 messages/会话)");
321
345
  else if (bound === void 0) why.push("该指纹无绑定(会话首次出现)");
322
346
  else if (!healthy.includes(bound)) {
323
- const c = this.cooldowns.get(this.key(modelId, bound));
324
- why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
347
+ const ba = byUid.get(bound);
348
+ if (!this.servableByReport(ba)) why.push(`原绑定 ${bound} 插件报额度/凭证不可用(${ba?.state})`);
349
+ else {
350
+ const c = this.cooldowns.get(this.key(modelId, bound));
351
+ why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
352
+ }
325
353
  } else if (!usable.includes(bound)) why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`);
326
354
  else why.push(`原绑定 ${bound} 不在当前健康池`);
327
355
  const block = this.blocks.get(this.blockKey(modelId));
@@ -1984,6 +2012,21 @@ var Router = class {
1984
2012
  log;
1985
2013
  /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
1986
2014
  firstByteBudgetMs;
2015
+ /**
2016
+ * 组合增删改**成功提交后**的通知出口。
2017
+ *
2018
+ * 为什么必须有:模型选择器(`ui-model-selection`)的目录是**客户端缓存**的,
2019
+ * 只在宿主转发过来的事件上失效重载(`llm/adapters-updated` /
2020
+ * `settings/document-updated` / 凭据事件 / 重连)。而组合只是本插件
2021
+ * `combos.json` 里的一份状态:改完名字若不发通知,选择器会一直拿着旧快照 ——
2022
+ * 用户能看到的症状是「面板里明明有这个名字,模型选择器里却找不到、切不过去」,
2023
+ * 且新建的组合永远不出现(2026-10-14 实测)。
2024
+ *
2025
+ * 通知与落盘分开({@link saveCombos} 先 {@link commitCombos} 落盘、再发通知):
2026
+ * 落盘失败、或 `stateFile` 为空(单测/独立运行时)时内存已经改了,客户端的
2027
+ * 状态同样过期,所以这两条路径都必须照发。
2028
+ */
2029
+ onCombosChanged;
1987
2030
  /** 用量统计(概览看板)。 */
1988
2031
  usage;
1989
2032
  constructor(stateFile = "", store, log, opts) {
@@ -1991,6 +2034,7 @@ var Router = class {
1991
2034
  this.store = store ?? new SupplierConfigStore(stateFile);
1992
2035
  this.log = log ?? (() => {});
1993
2036
  this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS;
2037
+ this.onCombosChanged = opts?.onCombosChanged;
1994
2038
  this.usage = new UsageStore(stateFile);
1995
2039
  this.loadCombos();
1996
2040
  }
@@ -2025,7 +2069,23 @@ var Router = class {
2025
2069
  }).filter((c) => c.models.length > 0);
2026
2070
  } catch {}
2027
2071
  }
2072
+ /**
2073
+ * 提交组合变更:落盘 + 通知拓扑观察者。
2074
+ *
2075
+ * `onCombosChanged` 的失败必须被吞掉:它是**通知**不是事务参与者,
2076
+ * 监听方(宿主事件总线)出问题不能把一次已经生效的增删改报成失败 ——
2077
+ * 那会让用户重试一个其实已经成功的操作(同 `schedule/changed` 的口径)。
2078
+ */
2028
2079
  saveCombos() {
2080
+ this.commitCombos();
2081
+ try {
2082
+ this.onCombosChanged?.();
2083
+ } catch (err) {
2084
+ this.log(`combo change notification failed: ${err.message}`);
2085
+ }
2086
+ }
2087
+ /** 组合落盘(通知由 {@link saveCombos} 单独发,落盘失败不阻断它)。 */
2088
+ commitCombos() {
2029
2089
  if (this.combosFp === "") return;
2030
2090
  try {
2031
2091
  const dir = dirname(this.combosFp);
@@ -2512,7 +2572,7 @@ var Router = class {
2512
2572
  for (;;) {
2513
2573
  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
2574
  if (uid === void 0) {
2515
- if (onlyUid !== void 0 && trace !== void 0) {
2575
+ if (onlyUid !== void 0 && !tried.has(onlyUid) && trace !== void 0) {
2516
2576
  trace.lastState = "unknown";
2517
2577
  trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`;
2518
2578
  }
@@ -4009,6 +4069,33 @@ function writeJson$1(res, status, body) {
4009
4069
  });
4010
4070
  res.end(payload);
4011
4071
  }
4072
+ /**
4073
+ * 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
4074
+ *
4075
+ * 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
4076
+ * body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
4077
+ * 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
4078
+ *
4079
+ * 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
4080
+ * 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
4081
+ * 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
4082
+ */
4083
+ function guard(handler) {
4084
+ return async (req, res) => {
4085
+ try {
4086
+ await handler(req, res);
4087
+ } catch (err) {
4088
+ if (res.headersSent) {
4089
+ res.end();
4090
+ return;
4091
+ }
4092
+ writeJson$1(res, err instanceof SyntaxError ? 400 : 500, {
4093
+ ok: false,
4094
+ error: err.message
4095
+ });
4096
+ }
4097
+ };
4098
+ }
4012
4099
  async function readBody$1(req, limit = 65536) {
4013
4100
  const chunks = [];
4014
4101
  let size = 0;
@@ -4067,14 +4154,14 @@ function supplierRoutes(base, loaded, store, router) {
4067
4154
  routes.push({
4068
4155
  kind: "exact",
4069
4156
  path: `${p}/models`,
4070
- handler: async (_req, res) => {
4157
+ handler: guard(async (_req, res) => {
4071
4158
  const models = await router.modelsOf(s.id);
4072
4159
  writeJson$1(res, 200, {
4073
4160
  ok: true,
4074
4161
  alias: s.getAlias(),
4075
4162
  models
4076
4163
  });
4077
- }
4164
+ })
4078
4165
  });
4079
4166
  routes.push({
4080
4167
  kind: "exact",
@@ -4202,7 +4289,7 @@ function supplierRoutes(base, loaded, store, router) {
4202
4289
  routes.push({
4203
4290
  kind: "exact",
4204
4291
  path: `${p}/models/toggle`,
4205
- handler: async (req, res) => {
4292
+ handler: guard(async (req, res) => {
4206
4293
  const body = JSON.parse(await readBody$1(req));
4207
4294
  store.get(s.id);
4208
4295
  const models = await router.modelsOf(s.id);
@@ -4216,12 +4303,12 @@ function supplierRoutes(base, loaded, store, router) {
4216
4303
  store.setModelEnabled(s.id, body.id, !!body.enabled);
4217
4304
  router.invalidateModels(s.id);
4218
4305
  writeJson$1(res, 200, { ok: true });
4219
- }
4306
+ })
4220
4307
  });
4221
4308
  routes.push({
4222
4309
  kind: "exact",
4223
4310
  path: `${p}/models/add`,
4224
- handler: async (req, res) => {
4311
+ handler: guard(async (req, res) => {
4225
4312
  const id = (JSON.parse(await readBody$1(req)).id ?? "").trim();
4226
4313
  if (id === "") {
4227
4314
  writeJson$1(res, 400, {
@@ -4240,7 +4327,7 @@ function supplierRoutes(base, loaded, store, router) {
4240
4327
  store.addCustomModel(s.id, id);
4241
4328
  router.invalidateModels(s.id);
4242
4329
  writeJson$1(res, 200, { ok: true });
4243
- }
4330
+ })
4244
4331
  });
4245
4332
  routes.push({
4246
4333
  kind: "exact",
@@ -4263,13 +4350,13 @@ function supplierRoutes(base, loaded, store, router) {
4263
4350
  routes.push({
4264
4351
  kind: "exact",
4265
4352
  path: `${p}/models/bulk`,
4266
- handler: async (req, res) => {
4353
+ handler: guard(async (req, res) => {
4267
4354
  const body = JSON.parse(await readBody$1(req));
4268
4355
  const models = await router.modelsOf(s.id);
4269
4356
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
4270
4357
  router.invalidateModels(s.id);
4271
4358
  writeJson$1(res, 200, { ok: true });
4272
- }
4359
+ })
4273
4360
  });
4274
4361
  routes.push({
4275
4362
  kind: "exact",
@@ -4297,12 +4384,12 @@ function supplierRoutes(base, loaded, store, router) {
4297
4384
  routes.push({
4298
4385
  kind: "exact",
4299
4386
  path: `${p}/models/fetch`,
4300
- handler: async (_req, res) => {
4387
+ handler: guard(async (_req, res) => {
4301
4388
  writeJson$1(res, 200, {
4302
4389
  ok: true,
4303
4390
  models: await router.modelsOf(s.id, true)
4304
4391
  });
4305
- }
4392
+ })
4306
4393
  });
4307
4394
  routes.push({
4308
4395
  kind: "exact",
@@ -4868,7 +4955,9 @@ function apply(rawContext) {
4868
4955
  if (!hostSupported) ctx.logger.warn(`[dsh-router] 宿主版本 ${hostVersion ?? "未知"} 低于本插件支持的 0.2.0 —— 插件仍会加载,但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。`);
4869
4956
  const store = new SupplierConfigStore(stateFile);
4870
4957
  const credentials = new CredentialStore(join(dataDir, "auths"));
4871
- const router = new Router(stateFile, store, log);
4958
+ const router = new Router(stateFile, store, log, { onCombosChanged: () => {
4959
+ ctx.emit("llm/adapters-updated");
4960
+ } });
4872
4961
  const keys = new KeysStore(stateFile);
4873
4962
  const disposers = [];
4874
4963
  const route = (path, handler) => {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,18 +1,25 @@
1
1
  /**
2
- * 提示词内容层 —— 准则 v5 的**逐字**副本,按原分隔拆成分类。
2
+ * 提示词内容层 —— 准则 v5 的副本,按原分隔拆成分类。
3
3
  *
4
4
  * ════════════════════════════════════════════════════════════════════
5
- * 为什么一字不改
5
+ * 为什么原则上不改措辞
6
6
  * ════════════════════════════════════════════════════════════════════
7
7
  * 这些字是**用户自己写的准则**,不是本插件的文案。任何"顺手润色"都是擅自
8
8
  * 改写用户的规则 —— 改出来的版本和用户心里的版本不一致,而用户看不见差异,
9
- * 于是准则开始按**另一份**文本执行。所以这里是逐字复制,**不做任何措辞优化**。
9
+ * 于是准则开始按**另一份**文本执行。所以改动只在**用户点名**时发生,不做
10
+ * 未被要求的措辞优化。
10
11
  *
11
12
  * 拆分只为「可开关」,不为了重排:顺序与原文出现顺序一致,`order` 也按此递增。
12
13
  *
13
14
  * ⚠️ **本文件是唯一事实源**。准则原本存放在 Mnemon 记忆系统的 `USER.md`;
14
15
  * 那份已经在插件验证生效后删除(否则新旧两份注入,每轮白烧一次 token 且
15
16
  * 永远有两份可能漂移的规则)。**改动准则 = 只改这里。**
17
+ *
18
+ * ⚠️ 2026-10-08 用户点名的两处改动(治「提示词导致思考过久」):
19
+ * ① 收口:加"只在任务完成时做",并**删掉第 ④ 条**「前三问都空时处置一件存量」
20
+ * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
+ * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
+ * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
16
23
  */
17
24
  /** 一个可独立开关的提示词分类。 */
18
25
  export interface PromptCategory {
@@ -66,6 +66,22 @@ export declare class AccountPool {
66
66
  * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
67
67
  */
68
68
  private healthy;
69
+ /**
70
+ * 插件报的「现在状态」是否意味着这个号此刻不该被选中。
71
+ *
72
+ * 只认两种**账号级持久事实**(要靠外部动作才能恢复,不是抖动):
73
+ * - `quota` —— 额度/积分用完,充值才能恢复(traework 积分归零时报它)
74
+ * - `session_dead` —— 凭证失效,重新登录才能恢复
75
+ *
76
+ * 其余一律不看:`transport`/`unknown`/`unavailable` 是插件某一次采样的瞬时
77
+ * 观察,拿它排除等于「一次抖动就踢号」,而且没有冷却计时器兜底;`rate_limit`
78
+ * 的指数退避由核心的冷却单元管,比状态采样准。
79
+ *
80
+ * 这是「插件只报现在怎么了、核心决定怎么办」的分工落地:插件报出它观察到
81
+ * 的事实,核心决定此刻不选它。恢复同样自动——充值/重登后插件报回 `ok`,
82
+ * 该号立刻回到候选(没有额外状态要清)。
83
+ */
84
+ private servableByReport;
69
85
  /**
70
86
  * 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
71
87
  *
@@ -81,11 +97,17 @@ export declare class AccountPool {
81
97
  * "策略",是无会话身份时的替身。
82
98
  * 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
83
99
  * 降权该号(缓存无效,可恢复)。
100
+ * 5. 插件报的 `quota`/`session_dead`(额度用完 / 凭证失效)→ **直接不选它**:
101
+ * 这类失败要靠充值/重登才能恢复,选它必是白撞一次(还有的供应商失败在
102
+ * 流已提交之后,这次请求根本救不回来)。见 `servableByReport`。
84
103
  *
85
104
  * 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
86
105
  * (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
87
106
  *
88
- * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
107
+ * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)。
108
+ * `state` 会被读:`quota`/`session_dead` 的号不进候选(见 `servableByReport`)。
109
+ * `credits` **不读** —— 不同供应商的 0 含义不同(openrouter/nvidia 的 0 是
110
+ * 「没有积分这个概念」),是否已用完由插件解读成 `state` 后报上来。
89
111
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
90
112
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
91
113
  * @param messages 请求体的 messages(算亲和指纹的内容兜底)