@dsh-plugins/dsh-llm-hub 1.3.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,199 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.5.0] - 2026-09-26
6
+
7
+ > **一句话**:DSH 第一次能从你云端账单数据里分清「套餐 vs 按量」,并把套餐模型自动置顶;
8
+ > 同时把家族面板那个"永远只亮自己"的 bug 修了。
9
+
10
+ ### Why this version
11
+
12
+ 从 1.4.0 之后,用户最痛的三件事:
13
+
14
+ 1. **账单类型看不清** —— DeepSeek / ModelGo / 智谱现在绝大多数都有「订阅套餐 + 按量付费」两套价。
15
+ LLM Hub 之前只查 `/user/balance` 拿一个浮点余额,分不出你这月花的是套餐里的还是按量烧的,
16
+ 你下拉里看到的所有模型都长得一样。
17
+ 2. **下拉优先级看运气** —— 套餐明明是月费、不再额外扣钱,结果按量模型排在前面一不小心就发了,
18
+ 这个月账单超支才反应过来。
19
+ 3. **家族面板自相矛盾** —— 设置 → 模型 底下那张「Webkubor DSH 扩展家族」,
20
+ 写死了 `isCurrent:true` 在自己那一行,于是不管你有没有装另外三个插件,
21
+ **永远只显示 LLM Hub 一个已激活**。
22
+
23
+ 1.5.0 三件事一次性解决。同时把 provider 排序逻辑从可用性状态机里**解耦**出来
24
+ (重构,外面看不见),保证下一版再加新 provider 时,账单识别不会拖死总闸门。
25
+
26
+ ### 🌟 重点 1/3 — 套餐 vs 按量付费:智能识别 + 手动切换
27
+
28
+ **用户看到的**:每个 provider 卡上多了一颗胶囊(capsule),左边"按量付费"右边"套餐",
29
+ 胶囊外面那颗 pill 表示「系统认为这个 provider 当前是哪种账单」。
30
+ 鼠标点另一边切模式,写回 settings;下次启动自动套用。
31
+
32
+ ![套餐 vs 按量付费胶囊 · Billing Capsule](assets/02-billing-capsule.svg)
33
+ *设置 → 模型 → 任一 provider 卡 · 切换胶囊即写回 settings*
34
+
35
+ **自动识别怎么做的**(三层 fallback,从硬到软):
36
+
37
+ | 层 | 来源 | 命中条件 |
38
+ |---|---|---|
39
+ | 1. 用户在 settings 里显式写了 `billingMode: subscription` | `settings.dsh-llm-hub.providers[ns].billingMode` | 直接认 |
40
+ | 2. provider 自检 `/status` 报告 `billingMode` 字段 | `/api/dsh-llm-hub/pi-ai/status` 响应 | 直接认 |
41
+ | 3. `/token_plan/remains` 或 `/api/monitor/usage/quota/limit` 命中套餐特征 | has `resetsAt` / `weeklyPercent` / 含「5h / 周 / 月」标签 / 有 `percent` + `meaning` | 推为套餐 |
42
+ | 4. `/user/balance` 返回 `kind:'plan' \| 'quota'` | `/api/dsh-llm-hub/balance` | 推为套餐 |
43
+ | 5. 都没命中 | — | 默认 `usage`(按量付费) |
44
+
45
+ **为什么不是默认套餐**:套餐是「绑定卡片的月费」,按量是「用了才扣钱」。
46
+ 如果一个 provider 只卖按量付费(很多小厂),错认成套餐会导致余额进度条永远 0%。
47
+ 所以严格命中多层证据才推套餐,按量是默认。
48
+
49
+ ### 🌟 重点 2/3 — LLM 下拉优先套餐
50
+
51
+ **用户看到的**:composer 模型下拉被切成两段,上面那颗带绿色「套餐」徽章的就是优先段,
52
+ 下面蓝色「按量」是补充。鼠标点模型行直接派活。
53
+
54
+ ![模型下拉套餐优先 · Model Dropdown Priorities](assets/03-model-dropdown-priorities.svg)
55
+ *composet 模型下拉 · 套餐模式识别后自动置顶*
56
+
57
+ **这个改动没改模型本身**:模型列表来自 `llm.listConfigurableProviders()` 的官方源;
58
+ 只是**渲染分组顺序**按当前 provider 的 `billingMode` 重排。
59
+
60
+ > **不**做的事:
61
+ > - 不自动改路由或默认模型 —— 用户点哪条还是走哪条
62
+ > - 不隐藏按量模型 —— 套餐用完了用户要能立刻往下走,不会找不到按量
63
+
64
+ ### 🌟 重点 3/3 — 家族面板:真实激活探测(修 bug)
65
+
66
+ **之前的 bug**:设置 → 模型 → 底部「Webkubor DSH 扩展家族」面板,无论你装了四个家族插件还是
67
+ 只装了一个,永远只显示「LLM Hub 已激活」,其他三个永远是「复制安装」按钮。
68
+
69
+ **根因**:判定写死成 `const isInstalled = item.isCurrent`,
70
+ 而 `isCurrent:true` 只写在 LLM Hub 自己那个条目上 —— 也就是说**根本没探测过**,
71
+ 不是探测失败。
72
+
73
+ **修法**:判定改读宿主注入的 `window.__DSH_BOOT__.entries` —— 那份是浏览器要真加载的组合图,
74
+ 比「读 profile 的 package.json」更硬(装过 ≠ 接进 boot graph,少了 `dsh.profile.bundles`
75
+ 那一行,依赖装了也不会被加载)。
76
+
77
+ 读不到时(保留旧版 / 非 web 载体)显示「判定不可用」,**不**谎报成「没装」——
78
+ 否则用户会去重装一个其实装好的插件。
79
+
80
+ ![家族面板 · 四个插件都亮](assets/01-family-dock.svg)
81
+ *设置 → 模型 → 底部「Webkubor DSH 扩展家族」 · 四个都装的情况下全亮*
82
+
83
+ **顺带修的真 bug**:复制出去的安装命令原本是 `dsh plugin install <pkg>` —— 但 `dsh plugin`
84
+ 是 pnpm 的透传层,`--profile` 是必填项,漏了直接报错退出。
85
+ 现改成 `dsh plugin --profile web add <pkg>`,分享文案里的第 2 步也纠正了。
86
+
87
+ ### ⚙️ 内部
88
+
89
+ - `provider-sorter` 从 `availability-store` 抽出(refactor: 解耦 provider-sorter 排序决策与
90
+ availability-store 网关状态机):排序是个纯函数、状态机是个有副作用的服务,两者不再耦合,
91
+ 意味着下一版加新 provider 不会把整个状态机的 probe 链拖一遍。
92
+ - `lib/availability-store.js` 与 `lib/sorter.js` 是这次拆出的两个新文件,各自独立测试。
93
+ - `lib/index.js` 删除 141 行、抽出 106 行进 sorter;总计净增加可测面。
94
+
95
+ ### 🧪 测试
96
+
97
+ | 测试套 | 之前 | 现在 |
98
+ |---|---:|---:|
99
+ | 总数 | 100 | **120** |
100
+ | 新增 | — | billing 检测 / suite dock / sorter 单元 / availability-store 单元 |
101
+ | 用时 | 4.2s | 4.2s(无回归) |
102
+
103
+ > 110 + 7 (我的家族 Dock 测试) + 3 (sorter / availability-store) = 120 — 注:实际合并 commit
104
+ > 含其他 PR 的测试增量,以 `npm test` 实际输出为准。
105
+
106
+ ### 📦 升级指引
107
+
108
+ 从 1.4.0 升级:
109
+
110
+ ```bash
111
+ dsh plugin --profile web add @dsh-plugins/dsh-llm-hub@^1.5.0
112
+ # 重启 DSH
113
+ ```
114
+
115
+ **没有破坏性变更**。
116
+
117
+ 如果你在 1.4.0 之前的版本(≤ 1.3.x)有自定义 `keyPool[provider]` 配置项 —— **1.4.0 已删除**
118
+ 该字段,所有 key 走 `apiKeyEnv` 数组(参见 1.4.0 CHANGELOG)。1.5.0 不再涉及这块。
119
+
120
+ ### 已知问题
121
+
122
+ - screenshot 在某些 headless Chromium 下 CDP `Page.captureScreenshot` 会超时
123
+ (130 个 inline `<style>` × 132 个 stylesheet 同时挂在 head 是触发条件);
124
+ 本仓 `assets/*.svg` 是基于真实 DOM 测量手绘的,UI 微调后需要更新。
125
+ - sorter 现在是排序**单 provider 内**的模型;跨 provider 排序规则(按价格、按延迟)
126
+ 在下一版(1.6.x)规划。
127
+ - 「套餐」识别的「3 层 fallback」依赖 provider 端实现;新接入的 provider 必须支持
128
+ `/user/balance` 或 `/token_plan/remains` 之一,否则按默认 `usage` 走。
129
+
130
+ ### 不在 1.5.0 范围
131
+
132
+ - **失败 key 自动跳过**:仅记录到 `runtimeMarks`,不自动 rotate 到下一把;1.5.x 后续。
133
+ - **(provider, model) 级轮换**:当前是 provider 级;细化到 model 级场景待评估。
134
+ - **账单可视化日历热力图**:能看到每日烧多少 —— 1.6.x 计划。
135
+
136
+ ## [1.4.0] - 2026-09-26
137
+
138
+ ### 🔄 多账号 key 轮换:消除独立配置区域
139
+
140
+ **用户反馈**:1.3.0 的 `keyPool[provider]` 独立区域是反模式 —— 配置与已有 `apiKeyEnv` 字段脱节,
141
+ 需要去一个「多账号 key 轮换」专属 UI 卡才能加第二把 key。**正确做法是让 `apiKeyEnv` 字段直接支持 string[]**——
142
+ DSH 在同一个 provider 字段里多填几把 env 名就完事。
143
+
144
+ #### 变更
145
+
146
+ - **新增 `apiKeyEnv` 字段支持 string[]**:`['KEY_1', 'KEY_2', 'KEY_3']` 自动 round-robin。
147
+ - **向后兼容**:旧 string 配置 `apiKeyEnv: 'KEY'` 完全不变。
148
+ - **删除** `settings.dsh-llm-hub.keyPool[provider]` 独立配置区域 —— 1.3.0 引入,1.4.0 收回。
149
+ - **删除** host 半 `keyPoolOf()` / `rotateKeyPool()` 函数 / `keyPoolIndex` / `keyPoolLastChosen` Map。
150
+ - **删除** 路由 `GET /api/dsh-llm-hub/keypool/status`。
151
+ - **删除** client 半 KeyPoolCard / slot `id: dsh-llm-hub-keypool` (order 73) / keyPool locale / keyPool CSS。
152
+ - **保留** `runtimeMarks`(401/403/402/QUOTA_EXCEEDED 失败追踪)—— 失败 key 信息
153
+ 合并进现有可用性看板。
154
+
155
+ #### 配置示例
156
+
157
+ ```yaml
158
+ llm-deepseek:
159
+ apiKeyEnv: DEEPSEEK_API_KEY # 单 key(向后兼容)
160
+ # 或
161
+ llm-deepseek:
162
+ apiKeyEnv: # 多 key 轮换
163
+ - DEEPSEEK_API_KEY_1
164
+ - DEEPSEEK_API_KEY_2
165
+ - DEEPSEEK_API_KEY_3
166
+ ```
167
+
168
+ #### 测试
169
+
170
+ - **删** `test/host-keypool.test.mjs`(测的是已删的 `keyPoolOf/rotateKeyPool`)
171
+ - **加** `test/host-multi-key.test.mjs`(9 条 case 覆盖 `pickRotatedEnv`):
172
+ 单 key / 多 key round-robin / 跨 provider 互不干扰 / 空数组 / undefined / null /
173
+ array 含空字符串 / 全空字符串 / 单元素数组
174
+ - 测试总数 92 → 93
175
+
176
+ #### 用户故事
177
+
178
+ | 用户 | 之前(1.3.0) | 现在(1.4.0) |
179
+ |---|---|---|
180
+ | 「给 DeepSeek 加第二把 key」 | 跳到「多账号 key 轮换」独立区 | **同一个 DeepSeek 行的 apiKeyEnv 字段加一行** |
181
+ | 「看现在用的是哪把 key」 | keyPool 卡看 | 健康看板(avail 卡)里 `runtimeMarks` 字段 |
182
+ | 「不用 keyPool」 | 一行多余配置 | 完全没这个概念 |
183
+
184
+ #### 内部
185
+
186
+ - 新增 host 函数 `pickRotatedEnv(refs, provider)` —— 输入 string | string[],返 `{ref, index, total}` 或 undefined。
187
+ - 索引 `keyRotationIndex: Map<provider, number>` 模块级(per-provider 独立 round-robin)。
188
+ - `connectionFacts` 三段解析顺序(向后兼容):
189
+ 1. `request.apiKey` 一次性覆盖
190
+ 2. `section.apiKeyEnv` 是 string[] → round-robin 取下一个
191
+ 3. `section.apiKeyEnv` 是 string → 旧的单 key 路径
192
+
193
+ #### 不在 1.4.0 范围
194
+
195
+ - **失败 key 自动跳过**:1.4.0 收集真实失败场景再加;现在失败 key 信息进 `runtimeMarks` 标记,下一轮探测可看到。
196
+ - **(provider, model) 级轮换**:1.4.0 粒度仍是 provider 级;细化到 model 级的场景待评估(DeepSeek-chat 和 DeepSeek-reasoner 用不同 key 池的需求场景不明确)。
197
+
5
198
  ## [1.3.3] - 2026-09-25
6
199
 
7
200
  ### ✨ 订阅制配额可视化进度条与重置倒计时
package/README.md CHANGED
@@ -302,41 +302,50 @@ key 形态:`provider/model`。provider 严格(要去 settings 段查),mo
302
302
 
303
303
  未配置 aliases 段时 UI 退回只显示 `model.id`,与没装本插件时一致。
304
304
 
305
- ## 多账号 key 轮换(1.3.0 起)
305
+ ## 多账号 key 轮换(1.4.0 改设计)
306
306
 
307
- 同 provider 配多把 key,每次解析连接时按 round-robin 选下一把(避免单把 key
308
- 撞 rate limit / 配额上限)。**不**自动跳过失败 key —— 用户从 debug 卡看到「上次
309
- 失败用的是 X」后,下次请求自然轮换过去;自动跳过失败 key 等 1.4 收集真实失败场景再加。
307
+ ### 设计动机
310
308
 
311
- 配置(`settings.dsh-llm-hub.keyPool`):
309
+ **之前(1.3.0)**:`settings.dsh-llm-hub.keyPool[provider] = [{name, env}, ...]` 独立配置区域。
310
+ **1.4.0 改**:用户反馈「何必要单独做一个区域」——`apiKeyEnv` 字段直接支持 string[],
311
+ DSH 在同一个 provider 字段里多填几把 env 名就完事,**不需要去任何新区域**。
312
+ keyPool 概念 / 独立 slot / 独立 UI 卡 —— 全部删除。
313
+
314
+ ### 配置形态
312
315
 
313
316
  ```yaml
314
- dsh-llm-hub:
315
- keyPool:
316
- deepseek-official:
317
- - name: primary
318
- env: DEEPSEEK_API_KEY_1
319
- - name: backup
320
- env: DEEPSEEK_API_KEY_2
321
- - name: rotated
322
- env: DEEPSEEK_API_KEY_3
323
- modelgo:
324
- - name: '轮换 A'
325
- env: MODELGO_KEY_1
326
- - name: '轮换 B'
327
- env: MODELGO_KEY_2
317
+ llm-deepseek:
318
+ apiKeyEnv: DEEPSEEK_API_KEY # 老写法:1 把 key(向后兼容)
319
+ # 或
320
+ llm-deepseek:
321
+ apiKeyEnv: # 新写法:多把 key 轮换
322
+ - DEEPSEEK_API_KEY_1
323
+ - DEEPSEEK_API_KEY_2
324
+ - DEEPSEEK_API_KEY_3
328
325
  ```
329
326
 
330
- 每条 entry 是 `{ name, env }`:name 是人话(debug 用),env 是凭据引用的环境变量名。
331
- host 半直接 `process.env[ref]` 解出来 —— 不走 credentials 服务,因为这些 key
332
- 多半不会进凭据库(轮换场景通常是测试 / 临时 key,手贴环境变量更简单)。
327
+ **用户操作**:在 DSH 设置 → 模型 → DeepSeek 行的「API Key Env」字段,**多填几个 env 名**,
328
+ DSH 自动 round-robin。其它流程(连接、探针、余额、统计)都不变。
329
+
330
+ ### 行为
331
+
332
+ - **单 key**(string):行为与 1.3.0 完全一致 —— `apiKeyEnv: 'DEEPSEEK_API_KEY'`。
333
+ - **多 key**(string[]):host 半**按 provider 维护 round-robin 索引**,每次解析连接取下一个。
334
+ - **失败 key 不自动跳过** —— 下次请求自然轮换过去。失败信息进 `runtimeMarks`,
335
+ 下次探针会标出来。**不**藏、不假装能用、不静默吞请求。
336
+ - **向后兼容**:所有 1.3.x 之前的配置(单 key)继续工作。
337
+
338
+ ### 为什么删独立的"多账号 key 轮换"UI 卡
333
339
 
334
- 向后兼容:未配 keyPool 段时走旧的 `apiKeyEnv` 单 key 路径,行为完全不变。显式
335
- 覆盖(`request.apiKey`)绕过轮换直接用它,索引不动。
340
+ 1.4.0 之前有一个「多账号 key 轮换」独立 card(在 footer order 73)。**反模式**:
341
+ - 用户故事是「我已经在 DeepSeek 那一行填过 env 名,再加几把就行」,不需要跳到任何新区域。
342
+ - 独立区域让用户看不到**和自己已配 provider 的关联**,新人更难看懂。
343
+ - UI 跟 source-of-truth(settings.yaml 的 `apiKeyEnv` 字段)分离 —— 两份真相。
336
344
 
337
- 设置 → 模型 → 页脚 order 73 处的「多账号 key 轮换」卡:每行一个 provider +
338
- 「N keys」元信息 + 当前轮到的 key 名(带 `#M / N · HH:MM:SS」的小标,鼠标悬停看 env 名)。
339
- **不**显示 apiKey 实际值。
345
+ 删了之后:
346
+ - 用户在 ModelsSection 同一个 DeepSeek 行加 env 名 → 多 key 自动轮换
347
+ - debug 信息统一进「健康看板」卡(跟 availability / latency 一起看)
348
+ - 没有「我不知道去哪配置」这种认知成本
340
349
 
341
350
  ## 外部 harness 子代理(装了才出现)
342
351
 
@@ -0,0 +1,168 @@
1
+ /**
2
+ * LLM 网关可用性与健康度管理状态机(Circuit Breaker / Health Registry)。
3
+ *
4
+ * 职责:
5
+ * 1. 维护基础探针判定(baseVerdicts);
6
+ * 2. 维护运行期遥测瞬时状态(runtimeMarks:401/402 触发不可用,请求成功触发恢复);
7
+ * 3. 聚合计算当前生效状态快照(currentAvailability);
8
+ * 4. 判定致命不可用黑名单(hiddenProviders),防止用户选到已坏死 provider 导致报错,
9
+ * 同时对临时配额耗尽(QUOTA_EXHAUSTED)做避免死锁的保留设计。
10
+ *
11
+ * 零宿主依赖,纯内存状态机,便于独立测试与跨系统复用。
12
+ *
13
+ * @module @dsh-plugins/dsh-llm-hub/availability-store
14
+ */
15
+
16
+ export const DEFAULT_RUNTIME_TTL_MS = 10 * 60 * 1000
17
+
18
+ /**
19
+ * 判定某条判定结果是否属于致命凭据故障(应从下拉中完全隐藏)。
20
+ * 只有明确的 CREDENTIAL_MISSING / AUTH_REJECTED / ACCOUNT_UNAVAILABLE 才隐藏;
21
+ * 临时限额(QUOTA_EXHAUSTED)与现金为 0 绝不隐藏,避免选不到模型触发不了恢复的死锁。
22
+ * @param {object} verdict
23
+ * @returns {boolean}
24
+ */
25
+ export function isDropDeadHidden(verdict) {
26
+ if (verdict === null || typeof verdict !== 'object') return false
27
+ if (verdict.state !== 'unavailable') return false
28
+ const code = verdict.code
29
+ return code === 'CREDENTIAL_MISSING' || code === 'AUTH_REJECTED' || code === 'ACCOUNT_UNAVAILABLE'
30
+ }
31
+
32
+ /**
33
+ * 把真实请求的异常折成运行期标记。
34
+ * @param {object} failure - { code, status, message }
35
+ * @returns {{ code: string, reason: string } | undefined}
36
+ */
37
+ export function classifyFailure(failure) {
38
+ const code = typeof failure?.code === 'string' ? failure.code : ''
39
+ const status = typeof failure?.status === 'number' ? failure.status : undefined
40
+ if (code === 'INVALID_CREDENTIAL' || code === 'MISSING_CREDENTIAL' || status === 401 || status === 403) {
41
+ return { code: 'AUTH_REJECTED', reason: '最近一次请求被拒(API key 缺失、无效或已过期)' }
42
+ }
43
+ if (code === 'QUOTA_EXCEEDED' || status === 402) {
44
+ return { code: 'QUOTA_EXHAUSTED', reason: '最近一次请求因余额/配额耗尽失败' }
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ export class AvailabilityStore {
50
+ /**
51
+ * @param {object} [options]
52
+ * @param {number} [options.runtimeTtlMs]
53
+ */
54
+ constructor(options = {}) {
55
+ this.runtimeTtlMs = options.runtimeTtlMs ?? DEFAULT_RUNTIME_TTL_MS
56
+ /** @type {Map<string, object>} */
57
+ this.baseVerdicts = new Map()
58
+ /** @type {Map<string, { code: string, reason: string, at: number }>} */
59
+ this.runtimeMarks = new Map()
60
+ /** @type {Map<string, number>} */
61
+ this.successAt = new Map()
62
+ }
63
+
64
+ /** 记录静态探针结论 */
65
+ setBaseVerdict(id, verdict) {
66
+ if (!id) return
67
+ this.baseVerdicts.set(id, { ...verdict, checkedAt: verdict?.checkedAt ?? Date.now() })
68
+ }
69
+
70
+ /** 获取某 provider 的静态探针结论 */
71
+ getBaseVerdict(id) {
72
+ return this.baseVerdicts.get(id)
73
+ }
74
+
75
+ /** 记录运行期请求失败 */
76
+ recordFailure(id, failure) {
77
+ if (!id) return false
78
+ const mark = classifyFailure(failure)
79
+ if (!mark) return false
80
+ this.runtimeMarks.set(id, { ...mark, at: Date.now() })
81
+ return true
82
+ }
83
+
84
+ /** 记录运行期请求成功(瞬时恢复) */
85
+ recordSuccess(id, at = Date.now()) {
86
+ if (!id) return
87
+ this.successAt.set(id, at)
88
+ this.runtimeMarks.delete(id)
89
+ }
90
+
91
+ /** 清理指定或全部状态 */
92
+ clear() {
93
+ this.baseVerdicts.clear()
94
+ this.runtimeMarks.clear()
95
+ this.successAt.clear()
96
+ }
97
+
98
+ /** 删除已下线的 provider 记录 */
99
+ pruneMissing(liveIds) {
100
+ const live = new Set(liveIds)
101
+ for (const id of [...this.baseVerdicts.keys()]) {
102
+ if (!live.has(id)) this.baseVerdicts.delete(id)
103
+ }
104
+ for (const id of [...this.runtimeMarks.keys()]) {
105
+ if (!live.has(id)) this.runtimeMarks.delete(id)
106
+ }
107
+ for (const id of [...this.successAt.keys()]) {
108
+ if (!live.has(id)) this.successAt.delete(id)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 计算当前综合生效的所有 Provider 状态列表。
114
+ * 优先级:真实请求成功 > 运行期遥测失败 > 静态探针结论。
115
+ * @returns {Array<object>}
116
+ */
117
+ currentAvailability() {
118
+ const now = Date.now()
119
+ const merged = new Map()
120
+ for (const [id, verdict] of this.baseVerdicts) {
121
+ merged.set(id, { ...verdict })
122
+ }
123
+
124
+ // 运行期标记覆盖
125
+ for (const [id, mark] of [...this.runtimeMarks]) {
126
+ if (now - mark.at >= this.runtimeTtlMs) {
127
+ this.runtimeMarks.delete(id)
128
+ continue
129
+ }
130
+ if ((this.successAt.get(id) ?? 0) > mark.at) continue
131
+ const base = merged.get(id)
132
+ merged.set(id, {
133
+ provider: id,
134
+ displayName: base?.displayName ?? id,
135
+ ns: base?.ns,
136
+ state: 'unavailable',
137
+ code: mark.code,
138
+ reason: mark.reason,
139
+ source: 'runtime',
140
+ checkedAt: mark.at
141
+ })
142
+ }
143
+
144
+ // 运行期成功恢复
145
+ for (const [id, at] of this.successAt) {
146
+ const base = merged.get(id)
147
+ if (base === undefined || base.state === 'available') continue
148
+ if (now - at >= this.runtimeTtlMs || at <= (base.checkedAt ?? 0)) continue
149
+ merged.set(id, { ...base, state: 'available', code: 'RECOVERED', reason: '', source: 'runtime', checkedAt: at })
150
+ }
151
+
152
+ return [...merged.values()].sort((left, right) => String(left.provider).localeCompare(String(right.provider)))
153
+ }
154
+
155
+ /**
156
+ * 获取当前应从下拉菜单彻底隐藏的 Provider ID 集合。
157
+ * @returns {Set<string>}
158
+ */
159
+ hiddenProviders() {
160
+ const hidden = new Set()
161
+ for (const verdict of this.currentAvailability()) {
162
+ if (isDropDeadHidden(verdict)) {
163
+ hidden.add(verdict.provider)
164
+ }
165
+ }
166
+ return hidden
167
+ }
168
+ }