@dsh-plugins/dsh-llm-hub 1.4.0 → 1.5.1

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,137 @@
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
+
5
136
  ## [1.4.0] - 2026-09-26
6
137
 
7
138
  ### 🔄 多账号 key 轮换:消除独立配置区域
@@ -0,0 +1,79 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="640" height="340" viewBox="0 0 640 340" font-family="-apple-system, 'SF Pro Text', 'PingFang SC', system-ui, sans-serif">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#1f1d2b"/>
5
+ <stop offset="100%" stop-color="#16151f"/>
6
+ </linearGradient>
7
+ <linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#2a2738"/>
9
+ <stop offset="100%" stop-color="#221f2c"/>
10
+ </linearGradient>
11
+ <radialGradient id="dot" cx="0.5" cy="0.5" r="0.5">
12
+ <stop offset="0%" stop-color="#34d399"/>
13
+ <stop offset="100%" stop-color="#10b981"/>
14
+ </radialGradient>
15
+ <filter id="blur"><feGaussianBlur stdDeviation="6"/></filter>
16
+ </defs>
17
+
18
+ <rect width="640" height="340" fill="url(#bg)"/>
19
+ <rect x="0" y="0" width="640" height="340" fill="#000" opacity="0.0"/>
20
+ <circle cx="80" cy="60" r="120" fill="#7c3aed" opacity="0.10" filter="url(#blur)"/>
21
+ <circle cx="560" cy="280" r="140" fill="#3b82f6" opacity="0.10" filter="url(#blur)"/>
22
+
23
+ <!-- panel container -->
24
+ <rect x="32" y="22" width="576" height="296" rx="12" fill="#1a1825" stroke="#2d2b3a" stroke-width="1"/>
25
+
26
+ <!-- head -->
27
+ <g transform="translate(48,46)">
28
+ <text x="0" y="14" font-size="14" font-weight="650" fill="#e5e7eb">🌟 Webkubor DSH 扩展家族</text>
29
+ <text x="384" y="14" font-size="10" fill="#9ca3af" text-anchor="end">一键构建完整智能体开发环境</text>
30
+ </g>
31
+
32
+ <!-- 4 cards grid 2x2, each 270x110 -->
33
+ <!-- card 1: Bloom Theme -->
34
+ <g transform="translate(48,82)">
35
+ <rect width="266" height="104" rx="8" fill="url(#card)" stroke="#2d2b3a"/>
36
+ <text x="14" y="24" font-size="14">🌸</text>
37
+ <text x="34" y="26" font-size="12" font-weight="600" fill="#e5e7eb">Bloom Theme</text>
38
+ <text x="14" y="50" font-size="10.5" fill="#9ca3af">落霞 Bloom 极客质感毛玻璃主题与代码高亮</text>
39
+ <circle cx="14" cy="78" r="3.5" fill="url(#dot)"/>
40
+ <text x="24" y="82" font-size="10.5" font-weight="600" fill="#10b981">已激活</text>
41
+ <text x="252" y="82" font-size="10" fill="#6b7280" text-anchor="end">GitHub ↗</text>
42
+ </g>
43
+
44
+ <!-- card 2: LLM Hub -->
45
+ <g transform="translate(326,82)">
46
+ <rect width="266" height="104" rx="8" fill="url(#card)" stroke="#2d2b3a"/>
47
+ <text x="14" y="24" font-size="14">🎛️</text>
48
+ <text x="34" y="26" font-size="12" font-weight="600" fill="#e5e7eb">LLM Hub</text>
49
+ <text x="14" y="50" font-size="10.5" fill="#9ca3af">多模型网关、余额配额、多 Key 轮换与极客大屏</text>
50
+ <circle cx="14" cy="78" r="3.5" fill="url(#dot)"/>
51
+ <text x="24" y="82" font-size="10.5" font-weight="600" fill="#10b981">已激活</text>
52
+ <text x="252" y="82" font-size="10" fill="#6b7280" text-anchor="end">GitHub ↗</text>
53
+ </g>
54
+
55
+ <!-- card 3: User Mirror -->
56
+ <g transform="translate(48,196)">
57
+ <rect width="266" height="104" rx="8" fill="url(#card)" stroke="#2d2b3a"/>
58
+ <text x="14" y="24" font-size="14">🪞</text>
59
+ <text x="34" y="26" font-size="12" font-weight="600" fill="#e5e7eb">User Mirror</text>
60
+ <text x="14" y="50" font-size="10.5" fill="#9ca3af">用户角色数字画像、习惯偏好与记忆网络</text>
61
+ <circle cx="14" cy="78" r="3.5" fill="url(#dot)"/>
62
+ <text x="24" y="82" font-size="10.5" font-weight="600" fill="#10b981">已激活</text>
63
+ <text x="252" y="82" font-size="10" fill="#6b7280" text-anchor="end">GitHub ↗</text>
64
+ </g>
65
+
66
+ <!-- card 4: Env Inspector -->
67
+ <g transform="translate(326,196)">
68
+ <rect width="266" height="104" rx="8" fill="url(#card)" stroke="#2d2b3a"/>
69
+ <text x="14" y="24" font-size="14">🖥️</text>
70
+ <text x="34" y="26" font-size="12" font-weight="600" fill="#e5e7eb">Env Inspector</text>
71
+ <text x="14" y="50" font-size="10.5" fill="#9ca3af">端口监听释放、CLI 工具链与环境大屏</text>
72
+ <circle cx="14" cy="78" r="3.5" fill="url(#dot)"/>
73
+ <text x="24" y="82" font-size="10.5" font-weight="600" fill="#10b981">已激活</text>
74
+ <text x="252" y="82" font-size="10" fill="#6b7280" text-anchor="end">GitHub ↗</text>
75
+ </g>
76
+
77
+ <!-- caption hint -->
78
+ <text x="320" y="332" font-size="9" fill="#6b7280" text-anchor="middle">设置 → 模型 → 底部「Webkubor DSH 扩展家族」</text>
79
+ </svg>
@@ -0,0 +1,72 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="720" height="280" viewBox="0 0 720 280" font-family="-apple-system, 'SF Pro Text', 'PingFang SC', system-ui, sans-serif">
2
+ <defs>
3
+ <linearGradient id="bg2" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#1f1d2b"/>
5
+ <stop offset="100%" stop-color="#16151f"/>
6
+ </linearGradient>
7
+ <linearGradient id="card2" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#2a2738"/>
9
+ <stop offset="100%" stop-color="#221f2c"/>
10
+ </linearGradient>
11
+ <linearGradient id="success" x1="0" y1="0" x2="1" y2="0">
12
+ <stop offset="0%" stop-color="#10b981"/>
13
+ <stop offset="100%" stop-color="#059669"/>
14
+ </linearGradient>
15
+ <linearGradient id="muted" x1="0" y1="0" x2="1" y2="0">
16
+ <stop offset="0%" stop-color="#3a3748"/>
17
+ <stop offset="100%" stop-color="#2d2b3a"/>
18
+ </linearGradient>
19
+ </defs>
20
+
21
+ <rect width="720" height="280" fill="url(#bg2)"/>
22
+
23
+ <!-- title bar -->
24
+ <text x="36" y="38" font-size="14" font-weight="650" fill="#e5e7eb">📦 模型卡片 · 套餐智能识别</text>
25
+ <text x="684" y="38" font-size="10.5" fill="#9ca3af" text-anchor="end">ModelGo 中转 · 11 个模型 · key 已配置</text>
26
+
27
+ <!-- one provider card expanded -->
28
+ <rect x="36" y="58" width="648" height="178" rx="10" fill="url(#card2)" stroke="#2d2b3a"/>
29
+
30
+ <!-- left: provider meta -->
31
+ <g transform="translate(56,82)">
32
+ <text x="0" y="0" font-size="12" font-weight="650" fill="#e5e7eb">ModelGo 中转</text>
33
+ <text x="0" y="18" font-size="10.5" fill="#9ca3af">https://api.modelgo.com · anthropic-messages</text>
34
+ <text x="0" y="36" font-size="10" fill="#6b7280">key ✓ · gpt-6-astra · claude-sonnet-4.5 · …</text>
35
+
36
+ <!-- billing capsule -->
37
+ <g transform="translate(0,58)">
38
+ <rect width="148" height="22" rx="11" fill="#1a1825" stroke="#2d2b3a"/>
39
+ <!-- left pill: pay-as-you-go active -->
40
+ <rect x="2" y="2" width="72" height="18" rx="9" fill="url(#success)"/>
41
+ <text x="38" y="15" font-size="10.5" font-weight="600" fill="#fff" text-anchor="middle">按量付费</text>
42
+ <!-- right pill: subscription -->
43
+ <rect x="76" y="2" width="68" height="18" rx="9" fill="url(#muted)"/>
44
+ <text x="110" y="15" font-size="10.5" fill="#d1d5db" text-anchor="middle">套餐</text>
45
+ </g>
46
+
47
+ <!-- caption -->
48
+ <text x="0" y="98" font-size="9.5" fill="#9ca3af">已按 /usage + /token_plan/remains 自动识别,</text>
49
+ <text x="0" y="112" font-size="9.5" fill="#9ca3af">可用时默认切到「套餐」分组。</text>
50
+ </g>
51
+
52
+ <!-- right: visual proof of toggle -->
53
+ <g transform="translate(420,82)">
54
+ <!-- before: scatter -->
55
+ <text x="0" y="0" font-size="10.5" fill="#9ca3af">切换前</text>
56
+ <rect x="0" y="10" width="240" height="60" rx="6" fill="#1a1825" stroke="#2d2b3a"/>
57
+ <text x="10" y="28" font-size="10" fill="#9ca3af">gpt-6-astra</text>
58
+ <text x="10" y="42" font-size="10" fill="#9ca3af">claude-sonnet-4.5</text>
59
+ <text x="10" y="56" font-size="10" fill="#9ca3af">deepseek-r1</text>
60
+
61
+ <!-- after: split -->
62
+ <text x="0" y="84" font-size="10.5" fill="#9ca3af">切换后</text>
63
+ <rect x="0" y="94" width="240" height="74" rx="6" fill="#1a1825" stroke="#2d2b3a"/>
64
+ <text x="10" y="112" font-size="10" font-weight="600" fill="#10b981">套餐</text>
65
+ <text x="50" y="112" font-size="10" fill="#d1d5db">claude-sonnet-4.5 · gpt-6-astra</text>
66
+ <text x="10" y="134" font-size="10" fill="#9ca3af">按量 · deepseek-r1 · qwen-2.5</text>
67
+ <text x="10" y="150" font-size="10" fill="#9ca3af">按量 · gemini-2.5-pro · …</text>
68
+ </g>
69
+
70
+ <!-- caption -->
71
+ <text x="360" y="266" font-size="9" fill="#6b7280" text-anchor="middle">设置 → 模型 → 单个 provider 卡 · 账单胶囊</text>
72
+ </svg>
@@ -0,0 +1,76 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" font-family="-apple-system, 'SF Pro Text', 'PingFang SC', system-ui, sans-serif">
2
+ <defs>
3
+ <linearGradient id="bg3" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#1f1d2b"/>
5
+ <stop offset="100%" stop-color="#16151f"/>
6
+ </linearGradient>
7
+ <linearGradient id="card3" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#2a2738"/>
9
+ <stop offset="100%" stop-color="#221f2c"/>
10
+ </linearGradient>
11
+ <linearGradient id="pillSub" x1="0" y1="0" x2="1" y2="0">
12
+ <stop offset="0%" stop-color="#10b981"/>
13
+ <stop offset="100%" stop-color="#059669"/>
14
+ </linearGradient>
15
+ <linearGradient id="pillUsage" x1="0" y1="0" x2="1" y2="0">
16
+ <stop offset="0%" stop-color="#3b82f6"/>
17
+ <stop offset="100%" stop-color="#2563eb"/>
18
+ </linearGradient>
19
+ </defs>
20
+
21
+ <rect width="720" height="360" fill="url(#bg3)"/>
22
+
23
+ <!-- composer surface (faded) -->
24
+ <rect x="0" y="0" width="720" height="360" fill="#0d0c12" opacity="0.5"/>
25
+ <text x="36" y="46" font-size="14" font-weight="650" fill="#e5e7eb">🎯 模型下拉 · 套餐优先</text>
26
+ <text x="684" y="46" font-size="10.5" fill="#9ca3af" text-anchor="end">composer 模型选择器</text>
27
+
28
+ <!-- mock composer input -->
29
+ <rect x="36" y="68" width="648" height="48" rx="8" fill="url(#card3)" stroke="#2d2b3a"/>
30
+ <text x="56" y="98" font-size="12" fill="#9ca3af">描述你想要构建的内容,/ 调用指令,@ 文件或对话</text>
31
+
32
+ <!-- dropdown panel -->
33
+ <g transform="translate(36,134)">
34
+ <rect width="648" height="206" rx="10" fill="url(#card3)" stroke="#2d2b3a"/>
35
+
36
+ <!-- section header: 套餐 -->
37
+ <g transform="translate(20,18)">
38
+ <rect width="40" height="14" rx="7" fill="url(#pillSub)"/>
39
+ <text x="20" y="11" font-size="9" font-weight="700" fill="#fff" text-anchor="middle">套餐</text>
40
+ <text x="48" y="11" font-size="10" fill="#10b981" font-weight="600">优先</text>
41
+ </g>
42
+
43
+ <!-- sub items -->
44
+ <g transform="translate(20,40)">
45
+ <rect width="608" height="26" rx="4" fill="#1f1d2b"/>
46
+ <text x="14" y="17" font-size="11" fill="#e5e7eb">claude-sonnet-4.5</text>
47
+ <text x="180" y="17" font-size="9" fill="#10b981">套餐 · 剩 42h / week</text>
48
+ </g>
49
+ <g transform="translate(20,70)">
50
+ <rect width="608" height="26" rx="4" fill="#1f1d2b"/>
51
+ <text x="14" y="17" font-size="11" fill="#e5e7eb">gpt-6-astra</text>
52
+ <text x="180" y="17" font-size="9" fill="#10b981">套餐 · 套餐余额 92%</text>
53
+ </g>
54
+
55
+ <!-- divider -->
56
+ <line x1="20" y1="106" x2="628" y2="106" stroke="#2d2b3a" stroke-dasharray="2 2"/>
57
+
58
+ <!-- section header: 按量 -->
59
+ <g transform="translate(20,114)">
60
+ <rect width="48" height="14" rx="7" fill="url(#pillUsage)"/>
61
+ <text x="24" y="11" font-size="9" font-weight="700" fill="#fff" text-anchor="middle">按量</text>
62
+ </g>
63
+
64
+ <g transform="translate(20,136)">
65
+ <rect width="608" height="26" rx="4" fill="#1f1d2b"/>
66
+ <text x="14" y="17" font-size="11" fill="#e5e7eb">deepseek-r1</text>
67
+ <text x="180" y="17" font-size="9" fill="#9ca3af">按量 · $3.40 / 1M</text>
68
+ </g>
69
+ <g transform="translate(20,166)">
70
+ <rect width="608" height="22" rx="4" fill="transparent"/>
71
+ <text x="14" y="16" font-size="10" fill="#9ca3af">展开其余 4 个按量模型</text>
72
+ </g>
73
+ </g>
74
+
75
+ <text x="360" y="350" font-size="9" fill="#6b7280" text-anchor="middle">套餐模式识别后自动置顶 · 单击切换模式走 settings</text>
76
+ </svg>
@@ -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
+ }