@dsh-plugins/dsh-llm-hub 1.3.2 → 1.4.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,80 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.4.0] - 2026-09-26
6
+
7
+ ### 🔄 多账号 key 轮换:消除独立配置区域
8
+
9
+ **用户反馈**:1.3.0 的 `keyPool[provider]` 独立区域是反模式 —— 配置与已有 `apiKeyEnv` 字段脱节,
10
+ 需要去一个「多账号 key 轮换」专属 UI 卡才能加第二把 key。**正确做法是让 `apiKeyEnv` 字段直接支持 string[]**——
11
+ DSH 在同一个 provider 字段里多填几把 env 名就完事。
12
+
13
+ #### 变更
14
+
15
+ - **新增 `apiKeyEnv` 字段支持 string[]**:`['KEY_1', 'KEY_2', 'KEY_3']` 自动 round-robin。
16
+ - **向后兼容**:旧 string 配置 `apiKeyEnv: 'KEY'` 完全不变。
17
+ - **删除** `settings.dsh-llm-hub.keyPool[provider]` 独立配置区域 —— 1.3.0 引入,1.4.0 收回。
18
+ - **删除** host 半 `keyPoolOf()` / `rotateKeyPool()` 函数 / `keyPoolIndex` / `keyPoolLastChosen` Map。
19
+ - **删除** 路由 `GET /api/dsh-llm-hub/keypool/status`。
20
+ - **删除** client 半 KeyPoolCard / slot `id: dsh-llm-hub-keypool` (order 73) / keyPool locale / keyPool CSS。
21
+ - **保留** `runtimeMarks`(401/403/402/QUOTA_EXCEEDED 失败追踪)—— 失败 key 信息
22
+ 合并进现有可用性看板。
23
+
24
+ #### 配置示例
25
+
26
+ ```yaml
27
+ llm-deepseek:
28
+ apiKeyEnv: DEEPSEEK_API_KEY # 单 key(向后兼容)
29
+ # 或
30
+ llm-deepseek:
31
+ apiKeyEnv: # 多 key 轮换
32
+ - DEEPSEEK_API_KEY_1
33
+ - DEEPSEEK_API_KEY_2
34
+ - DEEPSEEK_API_KEY_3
35
+ ```
36
+
37
+ #### 测试
38
+
39
+ - **删** `test/host-keypool.test.mjs`(测的是已删的 `keyPoolOf/rotateKeyPool`)
40
+ - **加** `test/host-multi-key.test.mjs`(9 条 case 覆盖 `pickRotatedEnv`):
41
+ 单 key / 多 key round-robin / 跨 provider 互不干扰 / 空数组 / undefined / null /
42
+ array 含空字符串 / 全空字符串 / 单元素数组
43
+ - 测试总数 92 → 93
44
+
45
+ #### 用户故事
46
+
47
+ | 用户 | 之前(1.3.0) | 现在(1.4.0) |
48
+ |---|---|---|
49
+ | 「给 DeepSeek 加第二把 key」 | 跳到「多账号 key 轮换」独立区 | **同一个 DeepSeek 行的 apiKeyEnv 字段加一行** |
50
+ | 「看现在用的是哪把 key」 | keyPool 卡看 | 健康看板(avail 卡)里 `runtimeMarks` 字段 |
51
+ | 「不用 keyPool」 | 一行多余配置 | 完全没这个概念 |
52
+
53
+ #### 内部
54
+
55
+ - 新增 host 函数 `pickRotatedEnv(refs, provider)` —— 输入 string | string[],返 `{ref, index, total}` 或 undefined。
56
+ - 索引 `keyRotationIndex: Map<provider, number>` 模块级(per-provider 独立 round-robin)。
57
+ - `connectionFacts` 三段解析顺序(向后兼容):
58
+ 1. `request.apiKey` 一次性覆盖
59
+ 2. `section.apiKeyEnv` 是 string[] → round-robin 取下一个
60
+ 3. `section.apiKeyEnv` 是 string → 旧的单 key 路径
61
+
62
+ #### 不在 1.4.0 范围
63
+
64
+ - **失败 key 自动跳过**:1.4.0 收集真实失败场景再加;现在失败 key 信息进 `runtimeMarks` 标记,下一轮探测可看到。
65
+ - **(provider, model) 级轮换**:1.4.0 粒度仍是 provider 级;细化到 model 级的场景待评估(DeepSeek-chat 和 DeepSeek-reasoner 用不同 key 池的需求场景不明确)。
66
+
67
+ ## [1.3.3] - 2026-09-25
68
+
69
+ ### ✨ 订阅制配额可视化进度条与重置倒计时
70
+
71
+ - **📊 订阅制可视化进度条胶囊(Subscription Quota Gauge)**:
72
+ - 参考 `claude-usage-statusline` 经典轨道式设计,在 Provider 卡片中引入高质感平滑迷你进度条;
73
+ - 动态三色渐变指示:`> 50%` 充裕翡翠绿(`#10b981`)、`15%~50%` 预警金黄(`#f59e0b`)、`<= 15%` 告急珊瑚红(`#ef4444`);
74
+ - 同时支持主限额(如 MiniMax 5 小时滚动配额、智谱 5h 配额)与次级周限额的双重进度条并列展示;
75
+ - **⏱️ 重置时刻与倒计时精准指示(Reset Time Tracking)**:
76
+ - 在配额进度条后实时标注重置时刻(如 `(15:40 重置)`),悬浮卡片提供完整余量与周期说明;
77
+ - 适配器底层自动解析服务端返回的 `current_interval_end_time` / `next_reset_time` / `expire_time` 真实重置时刻。
78
+
5
79
  ## [1.3.2] - 2026-09-24
6
80
 
7
81
  ### 🐛 修复与健壮性提升
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
 
package/lib/client.js CHANGED
@@ -75,8 +75,6 @@ window.__ModuleLoader__.load({
75
75
  const ROUTING_URL = '/api/dsh-llm-hub/routing/resolve'
76
76
  /** 模型别名:把长 id 显示成短名(人话)。 */
77
77
  const ALIASES_URL = '/api/dsh-llm-hub/aliases'
78
- /** 多账号 key 轮换:debug 路由,看每个 provider 当前轮到哪个 key。 */
79
- const KEYPOOL_URL = '/api/dsh-llm-hub/keypool/status'
80
78
 
81
79
  const LOCALES = {
82
80
  zh: {
@@ -158,10 +156,6 @@ window.__ModuleLoader__.load({
158
156
  routingNoRecommendation: '全部不可用',
159
157
  routingLoading: '路由解析中…',
160
158
  aliasesMissing: '尚未配置别名',
161
- keypoolTitle: '多账号 key 轮换',
162
- keypoolRound: 'round-robin',
163
- keypoolActiveTip: '当前优先使用',
164
- keypoolNextTip: '下次轮换目标',
165
159
  heroTitle: 'LLM 矩阵概览',
166
160
  heroProviders: '活跃提供方',
167
161
  heroModels: '可用模型',
@@ -273,10 +267,6 @@ window.__ModuleLoader__.load({
273
267
  routingNoRecommendation: 'none available',
274
268
  routingLoading: 'resolving routing…',
275
269
  aliasesMissing: 'no aliases configured',
276
- keypoolTitle: 'Key rotation',
277
- keypoolRound: 'round-robin',
278
- keypoolActiveTip: 'Currently active',
279
- keypoolNextTip: 'Next target',
280
270
  heroTitle: 'LLM Matrix Overview',
281
271
  heroProviders: 'Active providers',
282
272
  heroModels: 'Models',
@@ -510,22 +500,6 @@ window.__ModuleLoader__.load({
510
500
  '.dsh-llm-hub-routing__name{color:var(--dsw-alias-label-primary);font-weight:600}',
511
501
  '.dsh-llm-hub-routing__suggestion{color:var(--dsw-alias-state-warn-primary);padding-top:2px}',
512
502
  '.dsh-llm-hub-routing__reason{color:var(--dsw-alias-state-error-primary);padding-top:2px}',
513
- // 多账号 key 轮换
514
- '.dsh-llm-hub-keypool{display:flex;flex-direction:column;gap:6px;margin-top:10px;',
515
- 'padding:8px 10px;border-radius:6px;',
516
- 'border:.5px solid var(--dsw-alias-border-l1);background:transparent;',
517
- 'font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
518
- '.dsh-llm-hub-keypool__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}',
519
- '.dsh-llm-hub-keypool__title{color:var(--dsw-alias-label-primary);font-weight:600;display:inline-flex;align-items:center;gap:5px}',
520
- '.dsh-llm-hub-keypool__rows{display:flex;flex-direction:column;gap:3px}',
521
- '.dsh-llm-hub-keypool__row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:4px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-2,#1c1f24);border:.5px solid var(--dsw-alias-border-l1)}',
522
- '.dsh-llm-hub-keypool__row.is-active{border-color:var(--dsw-alias-state-success-primary,#10b981);box-shadow:0 0 8px rgba(16,185,129,.15)}',
523
- '.dsh-llm-hub-keypool__name{color:var(--dsw-alias-label-primary);font-weight:600;min-width:120px;font-family:ui-monospace,SFMono-Regular,monospace}',
524
- '.dsh-llm-hub-keypool__meta{font-size:10.5px;opacity:.7}',
525
- '.dsh-llm-hub-keypool__chosen{display:flex;align-items:baseline;gap:4px;',
526
- 'color:var(--dsw-alias-label-primary)}',
527
- '.dsh-llm-hub-keypool__chosen small{color:var(--dsw-alias-label-secondary);opacity:.75;font-size:10.5px}',
528
- '.dsh-llm-hub-keypool__pending{color:var(--dsw-alias-label-secondary);opacity:.6}',
529
503
  // Hero 全景统计大横条
530
504
  '.dsh-llm-hub-hero{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin:12px 0 6px 0}',
531
505
  '.dsh-llm-hub-hero__card{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.02));backdrop-filter:blur(6px)}',
@@ -547,6 +521,18 @@ window.__ModuleLoader__.load({
547
521
  '.dsh-llm-hub-latency-badge.is-fast{background:rgba(16,185,129,.08);color:var(--dsw-alias-state-success-primary,#10b981);border:.5px solid rgba(16,185,129,.2)}',
548
522
  '.dsh-llm-hub-latency-badge.is-normal{background:rgba(234,179,8,.08);color:var(--dsw-alias-state-warn-primary,#eab308);border:.5px solid rgba(234,179,8,.2)}',
549
523
  '.dsh-llm-hub-latency-badge.is-slow{background:rgba(239,68,68,.08);color:var(--dsw-alias-state-error-primary,#ef4444);border:.5px solid rgba(239,68,68,.2)}',
524
+ // 订阅制配额可视化进度条与重置倒计时(参考 claude-usage-statusline 规范)
525
+ '.dsh-llm-hub-quota-group{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;vertical-align:middle}',
526
+ '.dsh-llm-hub-quota-bar{display:inline-flex;align-items:center;gap:4.5px;padding:1.5px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.03));border:.5px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));font-size:10.5px;line-height:1;transition:all .15s ease}',
527
+ '.dsh-llm-hub-quota-bar:hover{border-color:var(--dsw-alias-brand-primary,rgba(59,130,246,.4));background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.06))}',
528
+ '.dsh-llm-hub-quota-label{font-weight:600;color:var(--dsw-alias-label-primary);font-size:10px}',
529
+ '.dsh-llm-hub-quota-track{width:46px;height:4.5px;border-radius:2.5px;background:rgba(255,255,255,.12);overflow:hidden;position:relative;display:inline-block;vertical-align:middle}',
530
+ '.dsh-llm-hub-quota-fill{height:100%;border-radius:2.5px;display:block;transition:width .3s cubic-bezier(0.4,0,0.2,1)}',
531
+ '.dsh-llm-hub-quota-fill.is-high{background:linear-gradient(90deg,#10b981,#34d399)}',
532
+ '.dsh-llm-hub-quota-fill.is-mid{background:linear-gradient(90deg,#f59e0b,#fbbf24)}',
533
+ '.dsh-llm-hub-quota-fill.is-low{background:linear-gradient(90deg,#ef4444,#f87171)}',
534
+ '.dsh-llm-hub-quota-pct{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary);font-size:10.5px}',
535
+ '.dsh-llm-hub-quota-time{font-size:9.5px;color:var(--dsw-alias-label-secondary);opacity:.8;font-variant-numeric:tabular-nums;display:inline-flex;align-items:center;margin-left:1px}',
550
536
  // Webkubor 插件全家桶互导矩阵
551
537
  '.dsh-suite-dock{margin-top:14px;padding:14px 16px;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,0.03));border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,0.08));border-radius:10px;backdrop-filter:blur(10px)}',
552
538
  '.dsh-suite-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;gap:8px}',
@@ -861,14 +847,44 @@ window.__ModuleLoader__.load({
861
847
  return `${t('balance')} ${symbol}${first.amount}${voucher}`
862
848
  }
863
849
  const meaning = balance.meaning === 'used' ? t('used') : t('remain')
864
- const parts = []
850
+ const isUsed = balance.meaning === 'used'
851
+ const bars = []
865
852
  for (const item of items.slice(0, 2)) {
866
- let piece = `${item.label} ${meaning} ${item.percent}%`
867
- if (typeof item.weeklyPercent === 'number') piece += ` · ${t('week')} ${meaning} ${item.weeklyPercent}%`
868
- parts.push(piece)
853
+ const remainPct = isUsed ? Math.max(0, 100 - (item.percent ?? 0)) : (item.percent ?? 0)
854
+ const fillPct = Math.min(Math.max(remainPct, 0), 100)
855
+ const levelClass = fillPct > 50 ? 'is-high' : (fillPct > 20 ? 'is-mid' : 'is-low')
856
+ const labelText = item.label || '5h'
857
+ const resetTimeNote = item.resetsAt ? `(${item.resetsAt} 重置)` : ''
858
+ bars.push(h('span', {
859
+ key: labelText,
860
+ className: 'dsh-llm-hub-quota-bar',
861
+ title: `${labelText} ${meaning} ${item.percent}%${resetTimeNote ? ` · ${resetTimeNote}` : ''}`
862
+ },
863
+ h('span', { className: 'dsh-llm-hub-quota-label' }, labelText),
864
+ h('span', { className: 'dsh-llm-hub-quota-track' },
865
+ h('span', { className: `dsh-llm-hub-quota-fill ${levelClass}`, style: { width: `${fillPct}%` } })
866
+ ),
867
+ h('span', { className: 'dsh-llm-hub-quota-pct' }, `${item.percent}%`),
868
+ resetTimeNote ? h('span', { className: 'dsh-llm-hub-quota-time' }, resetTimeNote) : null
869
+ ))
870
+ if (typeof item.weeklyPercent === 'number') {
871
+ const weeklyRemain = isUsed ? Math.max(0, 100 - item.weeklyPercent) : item.weeklyPercent
872
+ const weeklyFill = Math.min(Math.max(weeklyRemain, 0), 100)
873
+ const weeklyClass = weeklyFill > 50 ? 'is-high' : (weeklyFill > 20 ? 'is-mid' : 'is-low')
874
+ bars.push(h('span', {
875
+ key: 'week',
876
+ className: 'dsh-llm-hub-quota-bar',
877
+ title: `${t('week')} ${meaning} ${item.weeklyPercent}%`
878
+ },
879
+ h('span', { className: 'dsh-llm-hub-quota-label' }, t('week')),
880
+ h('span', { className: 'dsh-llm-hub-quota-track' },
881
+ h('span', { className: `dsh-llm-hub-quota-fill ${weeklyClass}`, style: { width: `${weeklyFill}%` } })
882
+ ),
883
+ h('span', { className: 'dsh-llm-hub-quota-pct' }, `${item.weeklyPercent}%`)
884
+ ))
885
+ }
869
886
  }
870
- const level = typeof balance.level === 'string' && balance.level.length > 0 ? `${balance.level} · ` : ''
871
- return `${t('planQuota')} ${level}${parts.join(' / ')}`
887
+ return h('span', { className: 'dsh-llm-hub-quota-group' }, bars)
872
888
  }
873
889
 
874
890
  /**
@@ -1378,50 +1394,6 @@ window.__ModuleLoader__.load({
1378
1394
  }
1379
1395
 
1380
1396
  /**
1381
- * 多账号 key 轮换卡:展示当前轮换池状态与活跃 Key
1382
- */
1383
- function KeyPoolCard(props) {
1384
- const t = typeof props.t === 'function' ? props.t : fallbackT
1385
- const [report, setReport] = React.useState(null)
1386
- React.useEffect(() => {
1387
- let alive = true
1388
- fetch(KEYPOOL_URL, { headers: { accept: 'application/json' } })
1389
- .then((res) => res.json())
1390
- .then((body) => { if (alive) setReport(body !== null && typeof body === 'object' ? body : null) })
1391
- .catch(() => { if (alive) setReport(null) })
1392
- return () => { alive = false }
1393
- }, [])
1394
- if (report === null) return null
1395
- const providers = Array.isArray(report.providers) ? report.providers : []
1396
- if (providers.length === 0) return null
1397
- ensureStyle()
1398
- const formatTime = (ms) => {
1399
- if (typeof ms !== 'number' || ms <= 0) return '—'
1400
- const d = new Date(ms)
1401
- return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
1402
- }
1403
- const rows = providers.map((entry) => {
1404
- const chosen = entry.lastChosen
1405
- const isActive = chosen !== null
1406
- return h('div', {
1407
- key: entry.provider,
1408
- className: 'dsh-llm-hub-keypool__row' + (isActive ? ' is-active' : '')
1409
- },
1410
- h('span', { className: 'dsh-llm-hub-health-dot ' + (isActive ? 'is-ok' : '') }),
1411
- h('span', { className: 'dsh-llm-hub-keypool__name' }, entry.provider),
1412
- h('span', { className: 'dsh-llm-hub-keypool__meta' }, `${entry.total} keys`),
1413
- chosen === null
1414
- ? h('span', { className: 'dsh-llm-hub-keypool__pending' }, '—')
1415
- : h('span', { className: 'dsh-llm-hub-keypool__chosen' },
1416
- h('span', null, chosen.name),
1417
- h('small', null, ` #${chosen.index + 1} / ${entry.total} · ${formatTime(chosen.at)}`)))
1418
- })
1419
- return h('div', { className: 'dsh-llm-hub-keypool' },
1420
- h('div', { className: 'dsh-llm-hub-keypool__head' },
1421
- h('span', { className: 'dsh-llm-hub-keypool__title' }, '🔄 ' + t('keypoolTitle')),
1422
- h('span', { className: 'dsh-llm-hub-subtext' }, t('keypoolRound'))),
1423
- h('div', { className: 'dsh-llm-hub-keypool__rows' }, rows))
1424
- }
1425
1397
 
1426
1398
  /**
1427
1399
  * 当前路由卡 —— 显示用户当前激活的 (provider, model) + 状态 + 推荐的 fallback。
@@ -1951,13 +1923,6 @@ window.__ModuleLoader__.load({
1951
1923
  locale: NS,
1952
1924
  inject: () => ({ t, availability })
1953
1925
  }, HealthCard))
1954
- ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
1955
- name: 'settings.models.footer',
1956
- id: 'dsh-llm-hub-keypool',
1957
- order: 73,
1958
- locale: NS,
1959
- inject: () => ({ t })
1960
- }, KeyPoolCard))
1961
1926
  ctx.slots.inject('settings.models.footer', () => ctx.slots.register({
1962
1927
  name: 'settings.models.footer',
1963
1928
  id: 'dsh-llm-hub-routing',
package/lib/index.js CHANGED
@@ -180,22 +180,27 @@ const ROUTING_RESOLVE_PATH = '/api/dsh-llm-hub/routing/resolve'
180
180
  /** 模型别名:客户端渲染 model id 时显示短名(典型场景:deepseek-reasoner → 推理)。 */
181
181
  const ALIASES_PATH = '/api/dsh-llm-hub/aliases'
182
182
  /**
183
- * 多账号 key 轮换:settings.dsh-llm-hub.keyPool[provider] 是 entry 数组。
183
+ * 1.4.0 多 Key 轮换纯函数:支持 string(单 key)或 string[](多 key round-robin)。
184
184
  *
185
- * 设计取舍:
186
- * - **向后兼容**:未配 keyPool 段时直接走原 apiKeyEnv 单 key 路径,行为完全
187
- * 不变;现有 80 个测试不破。
188
- * - **round-robin**(非失败感知):每次解析按索引取下一个 entry;不加锁、不跨
189
- * session 持久化(重启会从 0 开始)。失败的 key 不会自动跳过 —— 用户从
190
- * debug 路由看到「上次失败的是 X」,下次请求自然轮换过去。这是 1.x 的轻量方案,
191
- * 真要自动跳过失败 key 等 1.1 收集了真实失败场景再加。
192
- * - **不覆盖显式 key**:request.apiKey 是一次性覆盖(发现草稿用的),绕过
193
- * 轮换直接用它;轮换索引不动。
194
- * - **provider 范围**:DeepSeek 官方直连用 NS = 'llm-deepseek' 作为 key,pi-ai
195
- * provider 用 provider 自己的 id(modelgo / minimax ...);keyPool 段里
196
- * 直接用 provider id 作 key。
185
+ * @param {string|string[]|undefined} refs - 单 Key 或多 Key 环境变量名数组
186
+ * @param {string} provider - Provider 标识(用于维护独立索引)
187
+ * @param {Map<string, number>} [stateMap] - 轮换游标存储
188
+ * @returns {{ ref: string, index: number, total: number } | undefined}
197
189
  */
198
- const KEYPOOL_STATUS_PATH = '/api/dsh-llm-hub/keypool/status'
190
+ export function pickRotatedEnv(refs, provider, stateMap) {
191
+ if (typeof refs === 'string' && refs.length > 0) {
192
+ return { ref: refs, index: 0, total: 1 }
193
+ }
194
+ if (Array.isArray(refs)) {
195
+ const list = refs.filter((r) => typeof r === 'string' && r.trim().length > 0)
196
+ if (list.length === 0) return undefined
197
+ const current = stateMap ? (stateMap.get(provider) ?? 0) : 0
198
+ const index = current % list.length
199
+ if (stateMap) stateMap.set(provider, (current + 1) % list.length)
200
+ return { ref: list[index], index, total: list.length }
201
+ }
202
+ return undefined
203
+ }
199
204
  /** 路由配置从 settings 哪一段读 —— 顶层 dsh-llm-hub 段下,与 warning 同级。 */
200
205
  const ROUTING_KEY = 'routing'
201
206
  /** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
@@ -522,56 +527,23 @@ export function apply(ctx) {
522
527
  /**
523
528
  * 读 `settings.dsh-llm-hub.keyPool[provider]` 段。
524
529
  *
525
- * 每条 entry 是 `{ name, env }` —— name 是人话(debug 用),env 是
526
- * 凭据服务引用的环境变量名;后者用 `process.env[ref]` 直接读,**不**走
527
- * credentials 服务,因为这些 key 多半不会进凭据库(轮换场景通常是
528
- * 测试 / 临时 key,手贴环境变量更简单)。
529
- *
530
- * @returns 规范化后的 entry 数组(缺 name 或 env 的过滤掉);没配或非数组返 `undefined`。
531
- */
532
- const keyPoolOf = (provider) => {
533
- const section = sectionOf() ?? {}
534
- const raw = section.keyPool
535
- if (raw === null || typeof raw !== 'object') return undefined
536
- const entries = raw[provider]
537
- if (!Array.isArray(entries)) return undefined
538
- const out = []
539
- for (const entry of entries) {
540
- if (entry === null || typeof entry !== 'object') continue
541
- const name = typeof entry.name === 'string' && entry.name.trim().length > 0 ? entry.name.trim() : undefined
542
- const env = typeof entry.env === 'string' && entry.env.trim().length > 0 ? entry.env.trim() : undefined
543
- if (env === undefined) continue
544
- out.push({ name: name ?? env, env })
545
- }
546
- return out.length > 0 ? out : undefined
547
- }
548
-
549
530
  /**
550
- * 给一个 provider 取轮换到的下一个 entry + 索引推进 +1。
551
- * @returns `{ entry, index }` —— entry 是 { name, env };index 是 0-based 位置。
531
+ * 多 Key 自动轮换索引 —— per provider 维护独立的自增游标。
532
+ * @type {Map<string, number>}
552
533
  */
553
- const rotateKeyPool = (provider) => {
554
- const entries = keyPoolOf(provider)
555
- if (entries === undefined) return undefined
556
- const current = keyPoolIndex.current.get(provider) ?? 0
557
- const index = current % entries.length
558
- keyPoolIndex.current.set(provider, (current + 1) % entries.length)
559
- const entry = entries[index]
560
- keyPoolLastChosen.current.set(provider, { entry, index, at: Date.now() })
561
- return { entry, index }
562
- }
534
+ const keyRotationIndex = new Map()
563
535
 
564
536
  /**
565
537
  * 解析本次调用要用的连接事实。
566
538
  *
567
539
  * 解析顺序(向后兼容):
568
540
  * 1) request.apiKey 一次性覆盖 —— 跳过轮换、原样返回(不污染索引)
569
- * 2) keyPool[provider] 命中 → round-robin 取下一个,env var 解析密钥
570
- * 3) 旧的 `apiKeyEnv` 单 key 路径(行为完全不变)
541
+ * 2) section.apiKeyEnv 是 string[] → 按 provider 轮换取下一个 ref,
542
+ * 再走 credentials 服务或 process.env 解析密钥
543
+ * 3) section.apiKeyEnv 是 string → 原单 key 路径(行为完全不变)
571
544
  *
572
- * 返 ref 不变 —— 客户端拿到的是凭据引用(keyPool 的 env 名),实际值是
573
- * `process.env[ref]` 解出来的字符串;下游诊断(探针 / 余额路由)能继续
574
- * 看到当前用的是哪个 env。
545
+ * 返 ref 不变 —— 客户端拿到的是凭据引用;下游诊断(探针 / 余额路由)
546
+ * 能继续看到当前用的是哪个 env。
575
547
  * @param request - 可选的一次性覆盖(发现草稿会带 baseURL / apiKey / provider)。
576
548
  * @returns baseURL、凭据引用与可用密钥。
577
549
  */
@@ -590,14 +562,10 @@ export function apply(ctx) {
590
562
  return { baseURL, ref, apiKey: explicit }
591
563
  }
592
564
  const providerKey = label(request.provider) ?? DEEPSEEK_PROVIDER
593
- const pool = rotateKeyPool(providerKey)
594
- if (pool !== undefined) {
595
- const apiKey = label(process.env[pool.entry.env])
596
- if (apiKey !== undefined) return { baseURL, ref: pool.entry.env, apiKey }
597
- // 配了 keyPool 但 env var 没值 —— 退回旧单 key 路径,不让 keyPool
598
- // 静默吞掉请求。
599
- }
600
- const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
565
+ const picked = pickRotatedEnv(section.apiKeyEnv, providerKey, keyRotationIndex)
566
+ const ref = picked === undefined
567
+ ? (label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV)
568
+ : picked.ref
601
569
  let apiKey
602
570
  const credentials = ctx.get('credentials')
603
571
  if (credentials !== undefined) {
@@ -923,10 +891,21 @@ export function apply(ctx) {
923
891
  if (row === null || typeof row !== 'object') continue
924
892
  const percent = percentage(row.current_interval_remaining_percent)
925
893
  if (percent === undefined) continue
894
+ let resetsAt = undefined
895
+ const rawTime = row.current_interval_end_time ?? row.end_time ?? row.reset_time
896
+ if (typeof rawTime === 'number' && rawTime > 0) {
897
+ const date = new Date(rawTime > 1e11 ? rawTime : rawTime * 1000)
898
+ if (!isNaN(date.getTime())) {
899
+ resetsAt = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
900
+ }
901
+ } else if (typeof rawTime === 'string' && rawTime.trim() !== '') {
902
+ resetsAt = rawTime.trim()
903
+ }
926
904
  items.push({
927
905
  label: label(row.model_name) ?? 'plan',
928
906
  percent,
929
- weeklyPercent: percentage(row.current_weekly_remaining_percent)
907
+ weeklyPercent: percentage(row.current_weekly_remaining_percent),
908
+ resetsAt
930
909
  })
931
910
  }
932
911
  return { items }
@@ -953,7 +932,17 @@ export function apply(ctx) {
953
932
  if (tier === undefined) continue
954
933
  const percent = percentage(lim.percentage)
955
934
  if (percent === undefined) continue
956
- items.push({ label: tier, percent })
935
+ let resetsAt = undefined
936
+ const rawTime = lim.reset_time ?? lim.next_reset_time ?? body?.data?.expire_time
937
+ if (typeof rawTime === 'number' && rawTime > 0) {
938
+ const date = new Date(rawTime > 1e11 ? rawTime : rawTime * 1000)
939
+ if (!isNaN(date.getTime())) {
940
+ resetsAt = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
941
+ }
942
+ } else if (typeof rawTime === 'string' && rawTime.trim() !== '') {
943
+ resetsAt = rawTime.trim()
944
+ }
945
+ items.push({ label: tier, percent, resetsAt })
957
946
  }
958
947
  return { level: label(body?.data?.level), items }
959
948
  }
@@ -1841,10 +1830,6 @@ export function apply(ctx) {
1841
1830
  // 对照官方价目表,CSV 导出(`GET /api/dsh-llm-hub/usage/export`)已经做了。
1842
1831
  const usageTable = { current: null }
1843
1832
  const usageReady = { current: false }
1844
- // 多账号 key 轮换:每个 provider 一个 round-robin 索引。重启后归零,跨 session
1845
- // 不持久化 —— 失败的 key 不会自动跳过,下次自然轮换过去(看 debug 路由查清)。
1846
- const keyPoolIndex = { current: new Map() }
1847
- const keyPoolLastChosen = { current: new Map() }
1848
1833
  const recordUsage = (entry) => {
1849
1834
  const table = usageTable.current
1850
1835
  if (table === null) return
@@ -2371,55 +2356,6 @@ export function apply(ctx) {
2371
2356
  }
2372
2357
  }), 'dsh-llm-hub: aliases route')
2373
2358
 
2374
- /**
2375
- * KeyPool 调试路由 —— 让 client 看到每个 provider 当前轮到了哪个 entry。
2376
- * 不暴露 apiKey 实际值(只有 name + env 名);401 / 403 类失败时用户从
2377
- * 这里看「轮到哪个 env 上次失败」,下次请求轮换过去。
2378
- */
2379
- const keyPoolDebugPayload = () => {
2380
- const section = sectionOf() ?? {}
2381
- const raw = section.keyPool
2382
- const pool = raw !== null && typeof raw === 'object' ? raw : {}
2383
- const providers = []
2384
- for (const [provider, entries] of Object.entries(pool)) {
2385
- if (!Array.isArray(entries)) continue
2386
- const lastChosen = keyPoolLastChosen.current.get(provider)
2387
- providers.push({
2388
- provider,
2389
- total: entries.length,
2390
- lastChosen: lastChosen === undefined ? null : {
2391
- name: lastChosen.entry.name,
2392
- env: lastChosen.entry.env,
2393
- index: lastChosen.index,
2394
- at: lastChosen.at
2395
- },
2396
- nextIndex: keyPoolIndex.current.get(provider) ?? 0
2397
- })
2398
- }
2399
- return { ok: true, providers }
2400
- }
2401
-
2402
- ctx.effect(() => ctx.webServer.register({
2403
- kind: 'exact',
2404
- path: KEYPOOL_STATUS_PATH,
2405
- handler: (request, response) => {
2406
- if (request.method !== 'GET' && request.method !== 'HEAD') {
2407
- response.writeHead(405, { allow: 'GET, HEAD' })
2408
- response.end()
2409
- return
2410
- }
2411
- if (!sameOrigin(request)) {
2412
- sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
2413
- return
2414
- }
2415
- if (request.method === 'HEAD') {
2416
- response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2417
- response.end()
2418
- return
2419
- }
2420
- sendJson(response, 200, keyPoolDebugPayload())
2421
- }
2422
- }), 'dsh-llm-hub: keypool status route')
2423
2359
 
2424
2360
  // ── harness 子代理 ────────────────────────────────────────────────────────
2425
2361
 
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@dsh-plugins/dsh-llm-hub",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "types": "./types/index.d.ts",
7
8
  "exports": {
8
9
  ".": {
10
+ "types": "./types/index.d.ts",
9
11
  "default": "./lib/index.js"
10
12
  },
11
13
  "./client": {
@@ -26,7 +28,7 @@
26
28
  }
27
29
  },
28
30
  "scripts": {
29
- "deploy": "rsync -av --delete lib/ ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/lib/ && rsync -av package.json cordis.patch.yml README.md README.en.md LICENSE ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/",
31
+ "deploy": "rsync -av --delete lib/ ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/lib/ && rsync -av types package.json cordis.patch.yml README.md README.en.md LICENSE ~/.dsh/profiles/web/node_modules/@dsh-plugins/dsh-llm-hub/",
30
32
  "preview": "npm run deploy && open http://127.0.0.1:3080",
31
33
  "check": "for f in lib/*.js; do node --check \"$f\" || exit 1; done",
32
34
  "test": "node --test"
@@ -38,14 +40,21 @@
38
40
  "deepseek",
39
41
  "model-discovery",
40
42
  "balance",
41
- "llm",
42
- "model-availability",
43
- "availability",
43
+ "quota",
44
+ "rate-limit",
45
+ "usage",
46
+ "tokens",
47
+ "cost",
48
+ "dashboard",
49
+ "routing",
50
+ "failover",
51
+ "key-rotation",
52
+ "api-keys",
53
+ "harness",
44
54
  "subagent",
45
55
  "codex",
46
- "claude-code",
47
- "antigravity",
48
- "harness"
56
+ "claude",
57
+ "agy"
49
58
  ],
50
59
  "license": "MIT",
51
60
  "repository": {
@@ -65,6 +74,7 @@
65
74
  "README.en.md",
66
75
  "README.md",
67
76
  "cordis.patch.yml",
68
- "lib"
77
+ "lib",
78
+ "types"
69
79
  ]
70
80
  }
@@ -0,0 +1,3 @@
1
+ declare module '@deepseek-ai/dsh-subagent'
2
+ declare module '@deepseek-ai/dsh-session'
3
+ declare module '@deepseek-ai/dsh-storage-domain'
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @dsh-plugins/dsh-llm-hub TypeScript Definitions
3
+ */
4
+
5
+ export const name: 'dsh-llm-hub'
6
+ export const inject: string[]
7
+
8
+ export const MAX_RESPONSE_BYTES: number
9
+ export const AVAILABILITY_TTL_MS: number
10
+ export const STUCK_PROBE_MS: number
11
+ export const REQUEST_TIMEOUT_MS: number
12
+
13
+ export interface UsageRecord {
14
+ id: string
15
+ provider: string
16
+ model: string
17
+ month: string
18
+ tokensIn: number
19
+ tokensOut: number
20
+ tokensTotal: number
21
+ costUsd: number
22
+ calls: number
23
+ updatedAt: number
24
+ }
25
+
26
+ export const UsageRecordSchema: {
27
+ readonly id: 'string'
28
+ readonly provider: 'string'
29
+ readonly model: 'string'
30
+ readonly month: 'string'
31
+ readonly tokensIn: 'number'
32
+ readonly tokensOut: 'number'
33
+ readonly tokensTotal: 'number'
34
+ readonly costUsd: 'number'
35
+ readonly calls: 'number'
36
+ readonly updatedAt: 'number'
37
+ }
38
+
39
+ export function normalizeUsageForTest(usage: any): {
40
+ tokensIn: number
41
+ tokensOut: number
42
+ tokensTotal: number
43
+ }
44
+
45
+ export function recordUsageInto(table: any, entry: any): void
46
+ export function readBounded(response: any, url: string): Promise<string>
47
+ export function maskKey(key: string | null | undefined): string
48
+
49
+ export interface RotatedEnvResult {
50
+ ref: string
51
+ index: number
52
+ total: number
53
+ }
54
+
55
+ export function pickRotatedEnv(
56
+ refs: string | string[] | undefined,
57
+ provider: string,
58
+ stateMap?: Map<string, number>
59
+ ): RotatedEnvResult | undefined
60
+
61
+ export function apply(ctx: any): void
62
+