dsh-router-core 0.4.4 → 0.4.6

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 (147) 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 +22 -3
  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 +101 -15
  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/index.js +58 -30
  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/router/account-pool.d.ts +30 -3
  28. package/lib/types/router/index.d.ts +25 -0
  29. package/lib/types/router/types.d.ts +16 -4
  30. package/lib/types/router/usage-store.d.ts +4 -2
  31. package/lib/types/shared.d.ts +19 -0
  32. package/lib/types/suppliers/contract.d.ts +7 -1
  33. package/package.json +1 -1
  34. package/src/client/CheckinCard.tsx +0 -0
  35. package/src/client/CombosTab.tsx +0 -0
  36. package/src/client/EndpointTab.tsx +0 -0
  37. package/src/client/ExtDetail.tsx +0 -0
  38. package/src/client/ExtTab.tsx +0 -0
  39. package/src/client/ExtTestPanel.tsx +0 -0
  40. package/src/client/LastHitDock.tsx +0 -0
  41. package/src/client/Modal.tsx +0 -0
  42. package/src/client/RouterComponentsSection.tsx +0 -0
  43. package/src/client/RouterView.tsx +0 -0
  44. package/src/client/StatsTab.tsx +0 -0
  45. package/src/client/SupplierDetail.tsx +1 -5
  46. package/src/client/account-badge.test.ts +37 -0
  47. package/src/client/account-badge.ts +24 -0
  48. package/src/client/comp-button-scope.test.ts +0 -0
  49. package/src/client/comp-panel-layout.test.ts +0 -0
  50. package/src/client/env.d.ts +0 -0
  51. package/src/client/ext-panels.test.ts +0 -0
  52. package/src/client/ext-panels.ts +0 -0
  53. package/src/client/index.tsx +0 -0
  54. package/src/client/last-hit-store.ts +0 -0
  55. package/src/client/model-choice.test.ts +0 -0
  56. package/src/client/model-choice.ts +0 -0
  57. package/src/client/model-hint-copy.test.ts +0 -0
  58. package/src/client/model-hint-copy.ts +0 -0
  59. package/src/client/router-components.test.ts +0 -0
  60. package/src/client/router-components.ts +0 -0
  61. package/src/client/router.css +0 -0
  62. package/src/client/settings-nav-icon.ts +0 -0
  63. package/src/client/settings-section.tsx +0 -0
  64. package/src/client/sidebar-entry.ts +0 -0
  65. package/src/client/supplier-visibility.test.ts +0 -0
  66. package/src/client/supplier-visibility.ts +0 -0
  67. package/src/client/workspace-controller.ts +0 -0
  68. package/src/client/workspace-mount.tsx +0 -0
  69. package/src/credential-store.ts +0 -0
  70. package/src/data-dir.test.ts +0 -0
  71. package/src/data-dir.ts +0 -0
  72. package/src/ext/contract.ts +0 -0
  73. package/src/ext/store.test.ts +0 -0
  74. package/src/ext/store.ts +0 -0
  75. package/src/ext-prompt/assembly.test.ts +0 -0
  76. package/src/ext-prompt/content.ts +0 -0
  77. package/src/ext-prompt/ext-prompt.test.ts +0 -0
  78. package/src/ext-prompt/index.ts +0 -0
  79. package/src/ext-prompt/locale/en.json +0 -0
  80. package/src/ext-prompt/locale/zh.json +0 -0
  81. package/src/ext-prompt/logo.svg +0 -0
  82. package/src/ext-prompt/logo.ts +0 -0
  83. package/src/ext-prompt/mount.test.ts +0 -0
  84. package/src/ext-prompt/mount.ts +0 -0
  85. package/src/ext-prompt/plugin.ts +0 -0
  86. package/src/ext-prompt/render.test.ts +0 -0
  87. package/src/ext-prompt/render.ts +0 -0
  88. package/src/ext-prompt/row.json +0 -0
  89. package/src/ext-route.test.ts +0 -0
  90. package/src/ext-test/ext-test.test.ts +0 -0
  91. package/src/ext-test/index.ts +0 -0
  92. package/src/ext-test/locale/en.json +0 -0
  93. package/src/ext-test/locale/zh.json +0 -0
  94. package/src/ext-test/plugin.ts +0 -0
  95. package/src/ext-test/row.json +0 -0
  96. package/src/host-base-url.test.ts +0 -0
  97. package/src/host-base-url.ts +0 -0
  98. package/src/host-version.test.ts +0 -0
  99. package/src/host-version.ts +0 -0
  100. package/src/index.ts +20 -6
  101. package/src/keys.ts +0 -0
  102. package/src/llm/adapter.test.ts +54 -0
  103. package/src/llm/adapter.ts +24 -2
  104. package/src/router/account-pool.test.ts +109 -0
  105. package/src/router/account-pool.ts +52 -9
  106. package/src/router/combo-change.test.ts +125 -0
  107. package/src/router/disconnect-leak.test.ts +0 -0
  108. package/src/router/first-byte-budget.test.ts +0 -0
  109. package/src/router/index.ts +44 -4
  110. package/src/router/models-cache.test.ts +0 -0
  111. package/src/router/precise-dispatch.test.ts +0 -0
  112. package/src/router/prefix-affinity.ts +0 -0
  113. package/src/router/supplier-switch.test.ts +91 -0
  114. package/src/router/types.ts +16 -4
  115. package/src/router/usage-store.ts +4 -2
  116. package/src/router/usage-tokens.ts +0 -0
  117. package/src/router/usage.test.ts +0 -0
  118. package/src/shared.test.ts +35 -0
  119. package/src/shared.ts +28 -0
  120. package/src/supplier-config.test.ts +0 -0
  121. package/src/supplier-config.ts +0 -0
  122. package/src/suppliers/contract.ts +7 -2
  123. package/src/suppliers/http-state.test.ts +0 -0
  124. package/src/suppliers/http-state.ts +0 -0
  125. package/src/suppliers/loader.test.ts +53 -0
  126. package/src/suppliers/loader.ts +0 -0
  127. package/src/suppliers/nvidia/index.ts +0 -0
  128. package/src/suppliers/nvidia/locale/en.json +0 -0
  129. package/src/suppliers/nvidia/locale/zh.json +0 -0
  130. package/src/suppliers/nvidia/plugin.ts +0 -0
  131. package/src/suppliers/nvidia/row.json +0 -0
  132. package/src/suppliers/opencode/index.ts +0 -0
  133. package/src/suppliers/opencode/locale/en.json +0 -0
  134. package/src/suppliers/opencode/locale/zh.json +0 -0
  135. package/src/suppliers/opencode/plugin.test.ts +79 -25
  136. package/src/suppliers/opencode/plugin.ts +60 -29
  137. package/src/suppliers/opencode/row.json +0 -0
  138. package/src/suppliers/openrouter/index.ts +0 -0
  139. package/src/suppliers/openrouter/locale/en.json +0 -0
  140. package/src/suppliers/openrouter/locale/zh.json +0 -0
  141. package/src/suppliers/openrouter/plugin.ts +0 -0
  142. package/src/suppliers/openrouter/row.json +0 -0
  143. package/src/suppliers/probe.test.ts +0 -0
  144. package/src/suppliers/probe.ts +0 -0
  145. package/src/suppliers/registry.test.ts +0 -0
  146. package/src/suppliers/registry.ts +0 -0
  147. 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
  都报一次,于是一个都选不出来(表现为「组合越用越没号可用」)。所以「这个模型
@@ -119,7 +138,7 @@ js 只要知道这一条:**报 `rate_limit`/`transport`/`unknown` 只影响「
119
138
  type ChatOnceResult =
120
139
  | { ok: true; stream: ReadableStream<Uint8Array> } // 流式
121
140
  | { ok: true; status: number; body: string } // 非流式
122
- | { ok: false; state: AccountState; message: string } // 失败
141
+ | { ok: false; state: AccountState; message: string; resetAt?: number } // 失败
123
142
  ```
124
143
 
125
144
  要点:
@@ -132,7 +151,7 @@ type ChatOnceResult =
132
151
 
133
152
  | state | 核心处置 |
134
153
  |---|---|
135
- | `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零 |
154
+ | `rate_limit` | **指数退避**:首次 2s,每次翻倍,上限 5 分钟;成功后等级清零。失败项带 `resetAt`(上游声明的重置时刻,epoch ms)时**冷到该点**——频率配额的重置是小时级的,退避上限只会到点回来再撞 |
136
155
  | `quota` | 冷却 10 分钟 |
137
156
  | `session_dead` | **连接级冷却 30 分钟**(该号所有模型都停用,到期自愈;没有永久禁用——403 分不清凭证死活与风控误伤) |
138
157
  | `unavailable` / `transport` / `unknown` | 瞬时短冷却 30s(**每次都冷**,不攒次数) |
@@ -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
@@ -10,6 +10,19 @@ import { homedir } from "node:os";
10
10
  //#region src/shared.ts
11
11
  /** Same-origin API base the client panel fetches (host half serves it). */
12
12
  const ROUTER_API_BASE = "/router/api";
13
+ /**
14
+ * 外部 OpenAI 兼容客户端的归因标签(`/v1` 没有宿主内部头 `x-dsh-router-session`
15
+ * 时的兜底)——否则 usage 记录里全是匿名流量,出事反查不出是谁在打
16
+ * (2026-10-15 实测:296 条匿名记录对不上任何会话)。
17
+ *
18
+ * 取值优先级:`user`(OpenAI 标准终端用户字段)→ `prompt_cache_key`
19
+ * (Zed 等客户端的缓存键,常带线程粒度)→ `User-Agent`(保底到客户端粒度)。
20
+ * 值截断到 64 字符;三者皆无返回 undefined(保持匿名)。
21
+ */
22
+ function clientIdentityOf(body, userAgent) {
23
+ const tag = (kind, v) => typeof v === "string" && v !== "" ? `ext/${kind}:${v.slice(0, 64)}` : void 0;
24
+ return tag("user", body.user) ?? tag("cache", body.prompt_cache_key) ?? (userAgent !== void 0 && userAgent !== "" ? `ext/ua:${userAgent.slice(0, 64)}` : void 0);
25
+ }
13
26
  //#endregion
14
27
  //#region src/router/prefix-affinity.ts
15
28
  /**
@@ -116,7 +129,9 @@ const MINUTE = 6e4;
116
129
  *
117
130
  * 冷却策略对齐 9router 的 accountFallback / errorConfig:
118
131
  * - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
119
- * 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。
132
+ * 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。上游自己声明了
133
+ * **重置时刻**(resetAt,如 6004「将在 … 重置」)时冷到该点——频率配额是
134
+ * 小时级的,退避上限(5min)只会到点回来再撞。
120
135
  * - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
121
136
  * 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
122
137
  *
@@ -275,6 +290,24 @@ var AccountPool = class {
275
290
  return c.until <= now;
276
291
  }
277
292
  /**
293
+ * 插件报的「现在状态」是否意味着这个号此刻不该被选中。
294
+ *
295
+ * 只认两种**账号级持久事实**(要靠外部动作才能恢复,不是抖动):
296
+ * - `quota` —— 额度/积分用完,充值才能恢复(traework 积分归零时报它)
297
+ * - `session_dead` —— 凭证失效,重新登录才能恢复
298
+ *
299
+ * 其余一律不看:`transport`/`unknown`/`unavailable` 是插件某一次采样的瞬时
300
+ * 观察,拿它排除等于「一次抖动就踢号」,而且没有冷却计时器兜底;`rate_limit`
301
+ * 的指数退避由核心的冷却单元管,比状态采样准。
302
+ *
303
+ * 这是「插件只报现在怎么了、核心决定怎么办」的分工落地:插件报出它观察到
304
+ * 的事实,核心决定此刻不选它。恢复同样自动——充值/重登后插件报回 `ok`,
305
+ * 该号立刻回到候选(没有额外状态要清)。
306
+ */
307
+ servableByReport(a) {
308
+ return a?.state !== "quota" && a?.state !== "session_dead";
309
+ }
310
+ /**
278
311
  * 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
279
312
  *
280
313
  * 旧的两策略(fallback 取第一个 / round-robin 轮转)都删了:前者让所有
@@ -289,11 +322,17 @@ var AccountPool = class {
289
322
  * "策略",是无会话身份时的替身。
290
323
  * 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
291
324
  * 降权该号(缓存无效,可恢复)。
325
+ * 5. 插件报的 `quota`/`session_dead`(额度用完 / 凭证失效)→ **直接不选它**:
326
+ * 这类失败要靠充值/重登才能恢复,选它必是白撞一次(还有的供应商失败在
327
+ * 流已提交之后,这次请求根本救不回来)。见 `servableByReport`。
292
328
  *
293
329
  * 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
294
330
  * (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
295
331
  *
296
- * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
332
+ * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)。
333
+ * `state` 会被读:`quota`/`session_dead` 的号不进候选(见 `servableByReport`)。
334
+ * `credits` **不读** —— 不同供应商的 0 含义不同(openrouter/nvidia 的 0 是
335
+ * 「没有积分这个概念」),是否已用完由插件解读成 `state` 后报上来。
297
336
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
298
337
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
299
338
  * @param messages 请求体的 messages(算亲和指纹的内容兜底)
@@ -303,9 +342,9 @@ var AccountPool = class {
303
342
  pick(accounts, poolOrder, modelId, messages, session) {
304
343
  const now = Date.now();
305
344
  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));
345
+ 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
346
  if (healthy.length === 0) {
308
- this.lastWhy = "全池无健康号(都在冷却)";
347
+ this.lastWhy = "全池无健康号(都在冷却,或插件报的额度/凭证已不可用)";
309
348
  return;
310
349
  }
311
350
  const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now, session));
@@ -320,8 +359,12 @@ var AccountPool = class {
320
359
  if (fp === "") why.push("无指纹(拿不到 messages/会话)");
321
360
  else if (bound === void 0) why.push("该指纹无绑定(会话首次出现)");
322
361
  else if (!healthy.includes(bound)) {
323
- const c = this.cooldowns.get(this.key(modelId, bound));
324
- why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
362
+ const ba = byUid.get(bound);
363
+ if (!this.servableByReport(ba)) why.push(`原绑定 ${bound} 插件报额度/凭证不可用(${ba?.state})`);
364
+ else {
365
+ const c = this.cooldowns.get(this.key(modelId, bound));
366
+ why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
367
+ }
325
368
  } else if (!usable.includes(bound)) why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`);
326
369
  else why.push(`原绑定 ${bound} 不在当前健康池`);
327
370
  const block = this.blocks.get(this.blockKey(modelId));
@@ -445,8 +488,13 @@ var AccountPool = class {
445
488
  blockKey(modelId) {
446
489
  return `${this.supplierId}${SEP}${modelId}`;
447
490
  }
448
- /** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
449
- noteFailure(uid, modelId, state, message) {
491
+ /**
492
+ * 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。
493
+ * `resetAt` = 上游声明的重置时刻(epoch ms):限流退避优先冷到该点——
494
+ * 频率配额是小时级的,5min 上限的指数退避只会到点回来再撞 6004,
495
+ * 把号轮流打冷(2026-10-15 的 11 号全灭事故)。
496
+ */
497
+ noteFailure(uid, modelId, state, message, resetAt) {
450
498
  const rule = RULES[state];
451
499
  if (rule.cooldown === 0) return;
452
500
  if (state === "session_dead") {
@@ -460,7 +508,9 @@ var AccountPool = class {
460
508
  e.reason = message;
461
509
  if (rule.cooldown === "backoff") {
462
510
  e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL);
463
- e.until = Math.max(e.until, Date.now() + backoffMs(e.backoffLevel));
511
+ const backoffUntil = Date.now() + backoffMs(e.backoffLevel);
512
+ const until = resetAt !== void 0 && resetAt > Date.now() ? resetAt : backoffUntil;
513
+ e.until = Math.max(e.until, until);
464
514
  return;
465
515
  }
466
516
  if (rule.cooldown === "transient") {
@@ -1984,6 +2034,21 @@ var Router = class {
1984
2034
  log;
1985
2035
  /** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
1986
2036
  firstByteBudgetMs;
2037
+ /**
2038
+ * 组合增删改**成功提交后**的通知出口。
2039
+ *
2040
+ * 为什么必须有:模型选择器(`ui-model-selection`)的目录是**客户端缓存**的,
2041
+ * 只在宿主转发过来的事件上失效重载(`llm/adapters-updated` /
2042
+ * `settings/document-updated` / 凭据事件 / 重连)。而组合只是本插件
2043
+ * `combos.json` 里的一份状态:改完名字若不发通知,选择器会一直拿着旧快照 ——
2044
+ * 用户能看到的症状是「面板里明明有这个名字,模型选择器里却找不到、切不过去」,
2045
+ * 且新建的组合永远不出现(2026-10-14 实测)。
2046
+ *
2047
+ * 通知与落盘分开({@link saveCombos} 先 {@link commitCombos} 落盘、再发通知):
2048
+ * 落盘失败、或 `stateFile` 为空(单测/独立运行时)时内存已经改了,客户端的
2049
+ * 状态同样过期,所以这两条路径都必须照发。
2050
+ */
2051
+ onCombosChanged;
1987
2052
  /** 用量统计(概览看板)。 */
1988
2053
  usage;
1989
2054
  constructor(stateFile = "", store, log, opts) {
@@ -1991,6 +2056,7 @@ var Router = class {
1991
2056
  this.store = store ?? new SupplierConfigStore(stateFile);
1992
2057
  this.log = log ?? (() => {});
1993
2058
  this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS;
2059
+ this.onCombosChanged = opts?.onCombosChanged;
1994
2060
  this.usage = new UsageStore(stateFile);
1995
2061
  this.loadCombos();
1996
2062
  }
@@ -2025,7 +2091,23 @@ var Router = class {
2025
2091
  }).filter((c) => c.models.length > 0);
2026
2092
  } catch {}
2027
2093
  }
2094
+ /**
2095
+ * 提交组合变更:落盘 + 通知拓扑观察者。
2096
+ *
2097
+ * `onCombosChanged` 的失败必须被吞掉:它是**通知**不是事务参与者,
2098
+ * 监听方(宿主事件总线)出问题不能把一次已经生效的增删改报成失败 ——
2099
+ * 那会让用户重试一个其实已经成功的操作(同 `schedule/changed` 的口径)。
2100
+ */
2028
2101
  saveCombos() {
2102
+ this.commitCombos();
2103
+ try {
2104
+ this.onCombosChanged?.();
2105
+ } catch (err) {
2106
+ this.log(`combo change notification failed: ${err.message}`);
2107
+ }
2108
+ }
2109
+ /** 组合落盘(通知由 {@link saveCombos} 单独发,落盘失败不阻断它)。 */
2110
+ commitCombos() {
2029
2111
  if (this.combosFp === "") return;
2030
2112
  try {
2031
2113
  const dir = dirname(this.combosFp);
@@ -2512,7 +2594,7 @@ var Router = class {
2512
2594
  for (;;) {
2513
2595
  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
2596
  if (uid === void 0) {
2515
- if (onlyUid !== void 0 && trace !== void 0) {
2597
+ if (onlyUid !== void 0 && !tried.has(onlyUid) && trace !== void 0) {
2516
2598
  trace.lastState = "unknown";
2517
2599
  trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`;
2518
2600
  }
@@ -2536,7 +2618,7 @@ var Router = class {
2536
2618
  }
2537
2619
  return false;
2538
2620
  }
2539
- pool.noteFailure(uid, req.model, r.state, r.message);
2621
+ pool.noteFailure(uid, req.model, r.state, r.message, r.resetAt);
2540
2622
  pool.dropBlock(req.model);
2541
2623
  if (trace !== void 0) {
2542
2624
  trace.attempts += 1;
@@ -2976,16 +3058,18 @@ async function* translateSse(payloads) {
2976
3058
  usage
2977
3059
  };
2978
3060
  }
3061
+ const hasCompleteToolCall = complete.some((block) => block.kind === "tool-call");
2979
3062
  const reason = pendingFinish ?? { kind: "stop" };
3063
+ const resolved = reason.kind === "stop" && hasCompleteToolCall ? { kind: "tool-calls" } : reason;
2980
3064
  yield {
2981
3065
  type: "finish",
2982
- reason: reason.kind === "stop" && order.length === 0 ? {
3066
+ reason: resolved.kind === "stop" && order.length === 0 ? {
2983
3067
  kind: "error",
2984
3068
  failure: {
2985
3069
  message: "model returned a completed response with no content",
2986
3070
  code: EMPTY_RESPONSE_CODE
2987
3071
  }
2988
- } : reason
3072
+ } : resolved
2989
3073
  };
2990
3074
  return;
2991
3075
  }
@@ -4895,7 +4979,9 @@ function apply(rawContext) {
4895
4979
  if (!hostSupported) ctx.logger.warn(`[dsh-router] 宿主版本 ${hostVersion ?? "未知"} 低于本插件支持的 0.2.0 —— 插件仍会加载,但设置卡片、「最近命中」徽章与工具结果转发会走错路径。请把宿主升到 0.2.0 及以上。`);
4896
4980
  const store = new SupplierConfigStore(stateFile);
4897
4981
  const credentials = new CredentialStore(join(dataDir, "auths"));
4898
- const router = new Router(stateFile, store, log);
4982
+ const router = new Router(stateFile, store, log, { onCombosChanged: () => {
4983
+ ctx.emit("llm/adapters-updated");
4984
+ } });
4899
4985
  const keys = new KeysStore(stateFile);
4900
4986
  const disposers = [];
4901
4987
  const route = (path, handler) => {
@@ -5046,7 +5132,7 @@ function apply(rawContext) {
5046
5132
  }
5047
5133
  const lv = typeof peek.reasoning_effort === "string" && peek.reasoning_effort !== "" ? peek.reasoning_effort : "auto";
5048
5134
  const sessionRaw = req.headers["x-dsh-router-session"];
5049
- const session = typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0;
5135
+ const session = (typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0) ?? clientIdentityOf(peek, req.headers["user-agent"]);
5050
5136
  await router.chatCompletions({
5051
5137
  rawBody: body,
5052
5138
  stream: !!peek.stream,
File without changes
File without changes
File without changes