dsh-router-core 0.4.4 → 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 (143) 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/locale/en.json +0 -0
  11. package/lib/ext-prompt/locale/zh.json +0 -0
  12. package/lib/ext-prompt/package.json +0 -0
  13. package/lib/ext-test/locale/en.json +0 -0
  14. package/lib/ext-test/locale/zh.json +0 -0
  15. package/lib/ext-test/package.json +0 -0
  16. package/lib/index.js +69 -7
  17. package/lib/suppliers/nvidia/locale/en.json +0 -0
  18. package/lib/suppliers/nvidia/locale/zh.json +0 -0
  19. package/lib/suppliers/nvidia/package.json +0 -0
  20. package/lib/suppliers/opencode/locale/en.json +0 -0
  21. package/lib/suppliers/opencode/locale/zh.json +0 -0
  22. package/lib/suppliers/opencode/package.json +0 -0
  23. package/lib/suppliers/openrouter/locale/en.json +0 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +0 -0
  25. package/lib/suppliers/openrouter/package.json +0 -0
  26. package/lib/types/router/account-pool.d.ts +23 -1
  27. package/lib/types/router/index.d.ts +25 -0
  28. package/lib/types/router/types.d.ts +9 -1
  29. package/lib/types/shared.d.ts +6 -0
  30. package/package.json +1 -1
  31. package/src/client/CheckinCard.tsx +0 -0
  32. package/src/client/CombosTab.tsx +0 -0
  33. package/src/client/EndpointTab.tsx +0 -0
  34. package/src/client/ExtDetail.tsx +0 -0
  35. package/src/client/ExtTab.tsx +0 -0
  36. package/src/client/ExtTestPanel.tsx +0 -0
  37. package/src/client/LastHitDock.tsx +0 -0
  38. package/src/client/Modal.tsx +0 -0
  39. package/src/client/RouterComponentsSection.tsx +0 -0
  40. package/src/client/RouterView.tsx +0 -0
  41. package/src/client/StatsTab.tsx +0 -0
  42. package/src/client/SupplierDetail.tsx +1 -5
  43. package/src/client/account-badge.test.ts +37 -0
  44. package/src/client/account-badge.ts +24 -0
  45. package/src/client/comp-button-scope.test.ts +0 -0
  46. package/src/client/comp-panel-layout.test.ts +0 -0
  47. package/src/client/env.d.ts +0 -0
  48. package/src/client/ext-panels.test.ts +0 -0
  49. package/src/client/ext-panels.ts +0 -0
  50. package/src/client/index.tsx +0 -0
  51. package/src/client/last-hit-store.ts +0 -0
  52. package/src/client/model-choice.test.ts +0 -0
  53. package/src/client/model-choice.ts +0 -0
  54. package/src/client/model-hint-copy.test.ts +0 -0
  55. package/src/client/model-hint-copy.ts +0 -0
  56. package/src/client/router-components.test.ts +0 -0
  57. package/src/client/router-components.ts +0 -0
  58. package/src/client/router.css +0 -0
  59. package/src/client/settings-nav-icon.ts +0 -0
  60. package/src/client/settings-section.tsx +0 -0
  61. package/src/client/sidebar-entry.ts +0 -0
  62. package/src/client/supplier-visibility.test.ts +0 -0
  63. package/src/client/supplier-visibility.ts +0 -0
  64. package/src/client/workspace-controller.ts +0 -0
  65. package/src/client/workspace-mount.tsx +0 -0
  66. package/src/credential-store.ts +0 -0
  67. package/src/data-dir.test.ts +0 -0
  68. package/src/data-dir.ts +0 -0
  69. package/src/ext/contract.ts +0 -0
  70. package/src/ext/store.test.ts +0 -0
  71. package/src/ext/store.ts +0 -0
  72. package/src/ext-prompt/assembly.test.ts +0 -0
  73. package/src/ext-prompt/content.ts +0 -0
  74. package/src/ext-prompt/ext-prompt.test.ts +0 -0
  75. package/src/ext-prompt/index.ts +0 -0
  76. package/src/ext-prompt/locale/en.json +0 -0
  77. package/src/ext-prompt/locale/zh.json +0 -0
  78. package/src/ext-prompt/logo.svg +0 -0
  79. package/src/ext-prompt/logo.ts +0 -0
  80. package/src/ext-prompt/mount.test.ts +0 -0
  81. package/src/ext-prompt/mount.ts +0 -0
  82. package/src/ext-prompt/plugin.ts +0 -0
  83. package/src/ext-prompt/render.test.ts +0 -0
  84. package/src/ext-prompt/render.ts +0 -0
  85. package/src/ext-prompt/row.json +0 -0
  86. package/src/ext-route.test.ts +0 -0
  87. package/src/ext-test/ext-test.test.ts +0 -0
  88. package/src/ext-test/index.ts +0 -0
  89. package/src/ext-test/locale/en.json +0 -0
  90. package/src/ext-test/locale/zh.json +0 -0
  91. package/src/ext-test/plugin.ts +0 -0
  92. package/src/ext-test/row.json +0 -0
  93. package/src/host-base-url.test.ts +0 -0
  94. package/src/host-base-url.ts +0 -0
  95. package/src/host-version.test.ts +0 -0
  96. package/src/host-version.ts +0 -0
  97. package/src/index.ts +12 -1
  98. package/src/keys.ts +0 -0
  99. package/src/llm/adapter.test.ts +0 -0
  100. package/src/llm/adapter.ts +0 -0
  101. package/src/router/account-pool.test.ts +74 -0
  102. package/src/router/account-pool.ts +37 -5
  103. package/src/router/combo-change.test.ts +125 -0
  104. package/src/router/disconnect-leak.test.ts +0 -0
  105. package/src/router/first-byte-budget.test.ts +0 -0
  106. package/src/router/index.ts +43 -3
  107. package/src/router/models-cache.test.ts +0 -0
  108. package/src/router/precise-dispatch.test.ts +0 -0
  109. package/src/router/prefix-affinity.ts +0 -0
  110. package/src/router/supplier-switch.test.ts +91 -0
  111. package/src/router/types.ts +9 -1
  112. package/src/router/usage-store.ts +0 -0
  113. package/src/router/usage-tokens.ts +0 -0
  114. package/src/router/usage.test.ts +0 -0
  115. package/src/shared.ts +6 -0
  116. package/src/supplier-config.test.ts +0 -0
  117. package/src/supplier-config.ts +0 -0
  118. package/src/suppliers/contract.ts +0 -0
  119. package/src/suppliers/http-state.test.ts +0 -0
  120. package/src/suppliers/http-state.ts +0 -0
  121. package/src/suppliers/loader.test.ts +53 -0
  122. package/src/suppliers/loader.ts +0 -0
  123. package/src/suppliers/nvidia/index.ts +0 -0
  124. package/src/suppliers/nvidia/locale/en.json +0 -0
  125. package/src/suppliers/nvidia/locale/zh.json +0 -0
  126. package/src/suppliers/nvidia/plugin.ts +0 -0
  127. package/src/suppliers/nvidia/row.json +0 -0
  128. package/src/suppliers/opencode/index.ts +0 -0
  129. package/src/suppliers/opencode/locale/en.json +0 -0
  130. package/src/suppliers/opencode/locale/zh.json +0 -0
  131. package/src/suppliers/opencode/plugin.test.ts +0 -0
  132. package/src/suppliers/opencode/plugin.ts +0 -0
  133. package/src/suppliers/opencode/row.json +0 -0
  134. package/src/suppliers/openrouter/index.ts +0 -0
  135. package/src/suppliers/openrouter/locale/en.json +0 -0
  136. package/src/suppliers/openrouter/locale/zh.json +0 -0
  137. package/src/suppliers/openrouter/plugin.ts +0 -0
  138. package/src/suppliers/openrouter/row.json +0 -0
  139. package/src/suppliers/probe.test.ts +0 -0
  140. package/src/suppliers/probe.ts +0 -0
  141. package/src/suppliers/registry.test.ts +0 -0
  142. package/src/suppliers/registry.ts +0 -0
  143. 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,
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
  }
@@ -4895,7 +4955,9 @@ function apply(rawContext) {
4895
4955
  if (!hostSupported) ctx.logger.warn(`[dsh-router] 宿主版本 ${hostVersion ?? "未知"} 低于本插件支持的 0.2.0 —— 插件仍会加载,但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。`);
4896
4956
  const store = new SupplierConfigStore(stateFile);
4897
4957
  const credentials = new CredentialStore(join(dataDir, "auths"));
4898
- const router = new Router(stateFile, store, log);
4958
+ const router = new Router(stateFile, store, log, { onCombosChanged: () => {
4959
+ ctx.emit("llm/adapters-updated");
4960
+ } });
4899
4961
  const keys = new KeysStore(stateFile);
4900
4962
  const disposers = [];
4901
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
@@ -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(算亲和指纹的内容兜底)
@@ -36,10 +36,26 @@ export declare class Router {
36
36
  private log;
37
37
  /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
38
38
  private firstByteBudgetMs;
39
+ /**
40
+ * 组合增删改**成功提交后**的通知出口。
41
+ *
42
+ * 为什么必须有:模型选择器(`ui-model-selection`)的目录是**客户端缓存**的,
43
+ * 只在宿主转发过来的事件上失效重载(`llm/adapters-updated` /
44
+ * `settings/document-updated` / 凭据事件 / 重连)。而组合只是本插件
45
+ * `combos.json` 里的一份状态:改完名字若不发通知,选择器会一直拿着旧快照 ——
46
+ * 用户能看到的症状是「面板里明明有这个名字,模型选择器里却找不到、切不过去」,
47
+ * 且新建的组合永远不出现(2026-10-14 实测)。
48
+ *
49
+ * 通知与落盘分开({@link saveCombos} 先 {@link commitCombos} 落盘、再发通知):
50
+ * 落盘失败、或 `stateFile` 为空(单测/独立运行时)时内存已经改了,客户端的
51
+ * 状态同样过期,所以这两条路径都必须照发。
52
+ */
53
+ private onCombosChanged;
39
54
  /** 用量统计(概览看板)。 */
40
55
  usage: UsageStore;
41
56
  constructor(stateFile?: string, store?: SupplierConfigStore, log?: (msg: string) => void, opts?: {
42
57
  firstByteBudgetMs?: number;
58
+ onCombosChanged?: () => void;
43
59
  });
44
60
  /**
45
61
  * 请求追踪:每次 chat 记一行,回答「这个组合到底用的哪个模型和账号」。
@@ -48,7 +64,16 @@ export declare class Router {
48
64
  */
49
65
  private logChat;
50
66
  private loadCombos;
67
+ /**
68
+ * 提交组合变更:落盘 + 通知拓扑观察者。
69
+ *
70
+ * `onCombosChanged` 的失败必须被吞掉:它是**通知**不是事务参与者,
71
+ * 监听方(宿主事件总线)出问题不能把一次已经生效的增删改报成失败 ——
72
+ * 那会让用户重试一个其实已经成功的操作(同 `schedule/changed` 的口径)。
73
+ */
51
74
  private saveCombos;
75
+ /** 组合落盘(通知由 {@link saveCombos} 单独发,落盘失败不阻断它)。 */
76
+ private commitCombos;
52
77
  add(supplier: Supplier): void;
53
78
  /**
54
79
  * **参与路由的供应商** —— 装载清单里开关为开的那一份,按 priority 排好。
@@ -3,13 +3,21 @@
3
3
  * 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
4
4
  * 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
5
5
  */
6
- import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts';
6
+ import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts';
7
7
  import type { AccountPool } from './account-pool.ts';
8
8
  /** 面板展示的账号状态(脱敏)。 */
9
9
  export interface SupplierAccount {
10
10
  uid: string;
11
11
  nickname?: string;
12
12
  credits: number;
13
+ /**
14
+ * 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
15
+ *
16
+ * `decorate()` 一直原样带出插件报的状态,核心选号也读它(`quota`/`session_dead`
17
+ * 的号不进候选)——面板类型里缺了这一格就会自相矛盾:号明明已被排除在路由之外,
18
+ * 界面上却只看得到「0 积分」,看不出它为什么不参与。
19
+ */
20
+ state?: AccountState;
13
21
  cooling: boolean;
14
22
  until?: string;
15
23
  reason?: string;
@@ -19,6 +19,12 @@ export interface RouterAccount {
19
19
  uid: string;
20
20
  nickname?: string;
21
21
  credits: number;
22
+ /**
23
+ * 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
24
+ * 核心选号会跳过这些号,所以面板必须显示出来——否则界面写着「健康」,而路由
25
+ * 永远不选它,用户无从知道为什么。
26
+ */
27
+ state?: string;
22
28
  cooling: boolean;
23
29
  until?: string;
24
30
  reason?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
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
File without changes
File without changes
@@ -12,6 +12,7 @@ import {
12
12
  type RouterSupplierModel,
13
13
  type RouterSupplierModelsResponse,
14
14
  } from '../shared.ts'
15
+ import { statusBadge } from './account-badge.ts'
15
16
  import { Modal } from './Modal.tsx'
16
17
 
17
18
  /* ---------------- SVG 图标 ---------------- */
@@ -61,11 +62,6 @@ function untilLabel(account: RouterAccount): string {
61
62
  return `剩余 ${hours} 小时`
62
63
  }
63
64
 
64
- function statusBadge(account: RouterAccount): { text: string; cls: string } {
65
- if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
66
- return { text: '健康', cls: 'dshr-badge-ok' }
67
- }
68
-
69
65
  export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRefresh }: SupplierDetailProps): JSX.Element {
70
66
  const hasCap = (name: string): boolean => supplier.capabilities?.includes(name) ?? false
71
67
  // 连接池(账号池)由「添加链接」能力控制显示——没有添加链接 = 无账号概念,不显示连接池