@goodandready/dsh-key-rotation 0.8.7 → 0.8.10
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/README.md +22 -3
- package/README.ru.md +22 -3
- package/README.zh.md +22 -4
- package/lib/client.js +244 -5
- package/lib/concurrency.js +9 -0
- package/lib/heal.js +47 -0
- package/lib/index.js +47 -8
- package/lib/pool.js +104 -21
- package/lib/rotate.js +127 -107
- package/lib/routes-ops.js +38 -11
- package/lib/sandbox.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,12 +22,34 @@
|
|
|
22
22
|
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
|
+
<table align="center">
|
|
26
|
+
<tr>
|
|
27
|
+
<td align="center">
|
|
28
|
+
⭐ <strong>If you like this plugin, please star it on GitHub</strong> — it shows me that the plugin is useful to you and motivates me to keep developing it.
|
|
29
|
+
<br><br>
|
|
30
|
+
🐛 <strong>If you find a bug or would like to request a feature</strong>, open a GitHub issue in any language — I will review your proposal and implement useful suggestions in a future plugin version.
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</table>
|
|
34
|
+
|
|
25
35
|
</div>
|
|
26
36
|
|
|
27
37
|
---
|
|
28
38
|
|
|
29
39
|
## ⚡ Overview & The Problem
|
|
30
40
|
|
|
41
|
+
### 🚀 What's New in v0.8.10 (Stream Concurrency Hardening & Auto-Pruning)
|
|
42
|
+
- **Zero Concurrency Leaks**: Guaranteed release of stream concurrency slots via deterministic `try ... finally` block, preventing key starvation during clean finishes or client stream aborts.
|
|
43
|
+
- **Robust Probe Retry**: Added transient network socket error retry (`PROBE_RETRY_DELAY_MS`) in `SandboxRunner.probeModels` before marking keys as broken.
|
|
44
|
+
- **Memory & State Pruning**: Automated pruning of removed/stale keys from internal pool maps during periodic sweep cycles.
|
|
45
|
+
|
|
46
|
+
### 🚀 What's New in v0.8.9 (Routing Evolution & UI)
|
|
47
|
+
- **Proactive Rate-Limit Guard**: Automatic key pausing based on `x-ratelimit-remaining-*` and `Retry-After` headers before hitting 429 errors.
|
|
48
|
+
- **Self-Healing / Auto-Unbreak**: Periodic background probe via free `/models` endpoint to automatically revive broken keys without token burn.
|
|
49
|
+
- **Latency-Aware Routing**: Selectable strategies: `round-robin`, `least-loaded` (concurrency), and `lowest-latency` (p95 latency).
|
|
50
|
+
- **UI Evolution in `dsh-clinebot` Style**: Sequential batch "Test All Keys" runner with live progress, Live Event Stream drawer, and Quota Reset countdown badge.
|
|
51
|
+
- **Native Dual Language Support**: Native English (`en`) and Chinese (`zh`) UI dictionaries.
|
|
52
|
+
|
|
31
53
|
### 🛠️ What's New in v0.8.0 (Stability)
|
|
32
54
|
- **🔌 Circuit breaker**: after N consecutive provider failures the circuit opens and requests fail fast (`CIRCUIT_OPEN`) until a cool-down; half-open probes recover automatically.
|
|
33
55
|
- **🕒 Monotonic clock**: cooldown/breaker durations use process monotonic time so NTP steps cannot invert remaining times.
|
|
@@ -38,7 +60,6 @@
|
|
|
38
60
|
- **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
|
|
39
61
|
- **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
|
|
40
62
|
|
|
41
|
-
|
|
42
63
|
### 🛠️ What's New in v0.7.33 (Stability & Bugfix Release)
|
|
43
64
|
- **🔍 Resolved Key Probing BaseURL**: Fixed `resolveBaseUrl` to map key credential refs to owning provider pools, restoring live `probeModels` testing.
|
|
44
65
|
- **🛡️ Guarded Cascade Recursion**: Prevented call stack overflow in cross-provider failover when circular cascade chains occur.
|
|
@@ -49,7 +70,6 @@
|
|
|
49
70
|
- **🎨 Native Design System (Changed in v0.8.3)**: Unified with `dsh-clinebot` baseline: modular section cards, live pool telemetry stat boxes, pill badges, and complete semantic theme token styling (#281).
|
|
50
71
|
- **🌐 Localization (Changed in v0.8.2)**: Source strings are English-only. Russian/Chinese UI comes from the DSH core locale service and translation plugins (`props.t`). Active locale: host snapshot → first `navigator.languages` entry → `en` (#277).
|
|
51
72
|
|
|
52
|
-
|
|
53
73
|
### 🚀 What's New in v0.7.31
|
|
54
74
|
- **⚡ O(1) TokenBucket Accumulator**: Upgraded rate limiting math to O(1) time and zero-allocation memory with adaptive header synchronization.
|
|
55
75
|
- **🛡️ Soft vs Hard Backoff**: Differentiates transient infrastructure drops (502/503/timeouts: 10s flat cooldown) from hard quota errors (progressive doubling).
|
|
@@ -61,7 +81,6 @@
|
|
|
61
81
|
- **🧹 30-Day Usage Compaction**: Automatic bounded memory management with 30-day rolling window data pruning.
|
|
62
82
|
- **✨ Optimistic UI & Filter Pills**: Instant zero-latency UI updates on reset, plus `All`, `Ready`, `In Cooldown`, and `With Errors` quick filter chips.
|
|
63
83
|
|
|
64
|
-
|
|
65
84
|
High-throughput autonomous agent workflows, parallel subagent swarms, and multi-turn tool loops inevitably hit upstream API rate limits (HTTP 429, RPM/TPM exhaustion, daily quotas, or sudden provider outages). In standard DeepSeek Harness deployments, a single exhausted API key breaks the entire agent execution chain, requiring manual intervention and destroying the session's replay state.
|
|
66
85
|
|
|
67
86
|
**`dsh-key-rotation`** provides a seamless, enterprise-ready **transparent API key pooling, pre-emptive rate-limiting, and cross-provider failover engine** built natively on the Cordis microkernel architecture.
|
package/README.ru.md
CHANGED
|
@@ -22,12 +22,34 @@
|
|
|
22
22
|
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
|
+
<table align="center">
|
|
26
|
+
<tr>
|
|
27
|
+
<td align="center">
|
|
28
|
+
⭐ <strong>Если вам нравится этот плагин, поставьте ему звезду на GitHub</strong> — это покажет мне, что плагин вам полезен, и будет мотивировать меня развивать его дальше.
|
|
29
|
+
<br><br>
|
|
30
|
+
🐛 <strong>Если вы нашли баг или хотите предложить новый функционал</strong>, создайте issue на GitHub на любом языке — я рассмотрю ваше предложение и реализую полезные идеи в одной из следующих версий плагина.
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</table>
|
|
34
|
+
|
|
25
35
|
</div>
|
|
26
36
|
|
|
27
37
|
---
|
|
28
38
|
|
|
29
39
|
## ⚡ Обзор и решаемая проблема
|
|
30
40
|
|
|
41
|
+
### 🚀 Что нового в версии 0.8.10 (Надёжность стриминга и очистка памяти)
|
|
42
|
+
- **Устранение утечек счётчика соединений**: Гарантированное освобождение слота конкурентности в `try ... finally`, исключающее блокировку ключей при успешном завершении стрима или прерывании клиентом.
|
|
43
|
+
- **Повтор при сетевых сбоях проверки**: 1 автоматический повтор при кратковременных сокетных ошибках в `probeModels` перед дисквалификацией ключа.
|
|
44
|
+
- **Очистка устаревших ключей**: Периодическая автоматическая очистка удалённых ключей из внутренних карт состояния пула при регулярном цикле `sweep`.
|
|
45
|
+
|
|
46
|
+
### 🚀 Что нового в версии 0.8.9 (Развитие маршрутизации и UI)
|
|
47
|
+
- **Превентивная защита от лимитов (Proactive Rate-Limit Guard)**: Автоматическая пауза ключей по заголовкам `x-ratelimit-remaining-*` и `Retry-After` до наступления ошибки 429.
|
|
48
|
+
- **Авто-восстановление (Self-Healing / Auto-Unbreak)**: Периодический фоновый опрос эндпоинта `/models` для автоматического снятия статуса `broken` без расхода чат-токенов.
|
|
49
|
+
- **Маршрутизация по задержке (Latency-Aware Routing)**: Выбор стратегии балансировки: `round-robin`, `least-loaded` (по активным запросам) и `lowest-latency` (по p95 задержки).
|
|
50
|
+
- **Обновление интерфейса в стиле `dsh-clinebot`**: Пакетное тестирование всех ключей («Test All Keys») с прогрессом, аккордеон живого потока событий ротации (`.krot-event-stream`) и таймер обратного отсчета сброса квот.
|
|
51
|
+
- **Встроенная поддержка двух языков**: Готовые словари интерфейса на английском (`en`) и китайском (`zh`).
|
|
52
|
+
|
|
31
53
|
### 🛠️ Что нового в версии 0.8.0 (Стабильность)
|
|
32
54
|
- **🔌 Автомат выключения провайдера**: после N подряд ошибок контур открывается, запросы быстро завершаются (`CIRCUIT_OPEN`); half-open пробы восстанавливают сервис.
|
|
33
55
|
- **🕒 Монотонные часы**: cooldown/breaker считаются по процессным часам, NTP не ломает оставшееся время.
|
|
@@ -38,7 +60,6 @@
|
|
|
38
60
|
- **📡 Status**: `circuit` у провайдера + `meta.expectedClones` / `meta.notifyQueue`.
|
|
39
61
|
- **🧪 Smoke**: сценарий 429 → следующий ключ → успех.
|
|
40
62
|
|
|
41
|
-
|
|
42
63
|
### 🛠️ Что нового в версии 0.7.33 (Хотфикс и повышение стабильности)
|
|
43
64
|
- **🔍 Исправление проверки ключей**: Функция `resolveBaseUrl` теперь сопоставляет ref ключа с его пулом, восстанавливая работу живого тестирования моделей.
|
|
44
65
|
- **🛡️ Защита от зацикливания каскада**: Устранена возможность переполнения стека при взаимных кольцевых цепочках failover.
|
|
@@ -47,7 +68,6 @@
|
|
|
47
68
|
- **⚡ Сброс зависших блокировок**: Алгоритм `pickLeastLoaded` теперь учитывает протухшие блокировки при выборе наименее нагруженного ключа.
|
|
48
69
|
- **🌐 Полная китайская локализация**: Добавлен словарь `zh` в веб-интерфейс настроек для соблюдения стандарта трёх языков (EN/RU/ZH).
|
|
49
70
|
|
|
50
|
-
|
|
51
71
|
### 🚀 Что нового в версии 0.7.31
|
|
52
72
|
- **⚡ $O(1)$ Накопитель TokenBucket**: Оптимизация математики лимитов до $O(1)$ по времени и без аллокаций памяти с адаптивной синхронизацией по заголовкам.
|
|
53
73
|
- **🛡️ Разделение Soft / Hard сбоев**: Кратковременные сетевые сбои (502/503/таймауты) получают короткий 10-секундный кулдаун без штрафного удвоения.
|
|
@@ -59,7 +79,6 @@
|
|
|
59
79
|
- **🧹 30-дневная компактизация**: Автоматическая очистка истории старше 30 дней для защиты от утечек памяти.
|
|
60
80
|
- **✨ Оптимистичный UI и фильтры**: Мгновенный отклик кнопок сброса и статус-пилюли (`Все`, `Готовы`, `В кулдауне`, `С ошибками`) над списком ключей.
|
|
61
81
|
|
|
62
|
-
|
|
63
82
|
При активной работе автономных агентов, параллельном запуске субагентов и циклических вызовах инструментов запросы неизбежно упираются в ограничения провайдеров (ошибки HTTP 429 Too Many Requests, исчерпание суточных квот или временные сбои на стороне апстрима). В стандартной конфигурации DeepSeek Harness исчерпание одного API-ключа полностью блокирует цепочку рассуждений агента, ломает сохранённое состояние диалога (Replay State) и требует ручного вмешательства администратора.
|
|
64
83
|
|
|
65
84
|
**`dsh-key-rotation`** реализует отказоустойчивую корпоративную архитектуру **пулов API-ключей с предиктивным контролем лимитов (Token Bucket) и каскадным переключением на резервных провайдеров**, нативно интегрированную в микроядро Cordis.
|
package/README.zh.md
CHANGED
|
@@ -22,12 +22,34 @@
|
|
|
22
22
|
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
|
+
<table align="center">
|
|
26
|
+
<tr>
|
|
27
|
+
<td align="center">
|
|
28
|
+
⭐ <strong>如果您喜欢这个插件,请在 GitHub 上为它点亮 Star</strong> — 这能让我知道插件对您有用,并鼓励我继续开发和维护它。
|
|
29
|
+
<br><br>
|
|
30
|
+
🐛 <strong>如果您发现 Bug 或希望增加功能</strong>,请使用任意语言在 GitHub 上提交 Issue — 我会评估您的建议,并在后续版本中实现有价值的改进。
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</table>
|
|
34
|
+
|
|
25
35
|
</div>
|
|
26
36
|
|
|
27
37
|
---
|
|
28
38
|
|
|
29
39
|
## ⚡ 概述与核心痛点
|
|
30
40
|
|
|
41
|
+
### 🚀 v0.8.10 版本新特性(流并发加固与状态自动修剪)
|
|
42
|
+
- **杜绝并发计数泄漏**:在 `try ... finally` 中强制释放流并发占用,防止在正常完成或客户端中断时密钥被永久锁定。
|
|
43
|
+
- **探测抗网络抖动重试**:在 `probeModels` 中遇到套接字网络临时错误时自动重试一次,避免密钥被误判损坏。
|
|
44
|
+
- **内存与过期状态自动清理**:在定期清理周期中自动清除已删除密钥的内部映射记录。
|
|
45
|
+
|
|
46
|
+
### 🚀 v0.8.9 版本新特性(智能路由与界面升级)
|
|
47
|
+
- **前瞻性限流防护 (Proactive Rate-Limit Guard)**:根据 `x-ratelimit-remaining-*` 和 `Retry-After` 响应头在触发 429 错误前自动预冷密钥。
|
|
48
|
+
- **自愈恢复 (Self-Healing / Auto-Unbreak)**:通过免费的 `/models` 接口进行定期后台探测,自动恢复处于 `broken` 状态的密钥,无需消耗聊天代币。
|
|
49
|
+
- **延迟感知路由 (Latency-Aware Routing)**:可选路由策略:`round-robin`(轮询)、`least-loaded`(并发负载最低)与 `lowest-latency`(p95 延迟最低)。
|
|
50
|
+
- **`dsh-clinebot` 风格界面升级**:支持带进度提示的批量“测试所有密钥”(Test All Keys)、实时事件流折叠抽屉(Live Event Stream)及配额重置倒计时徽标。
|
|
51
|
+
- **原生中英文双语支持**:内置英文(`en`)与中文(`zh`)用户界面词典。
|
|
52
|
+
|
|
31
53
|
### 🛠️ v0.8.0 版本新特性(稳定性)
|
|
32
54
|
- **🔌 熔断器**:连续失败后快速失败(`CIRCUIT_OPEN`),半开探测自动恢复。
|
|
33
55
|
- **🕒 单调时钟**:冷却/熔断使用进程单调时间,NTP 校时不会颠倒剩余时间。
|
|
@@ -38,7 +60,6 @@
|
|
|
38
60
|
- **📡 Status**:提供商 `circuit` + `meta`。
|
|
39
61
|
- **🧪 Smoke**:429 → 切换密钥 → 成功。
|
|
40
62
|
|
|
41
|
-
|
|
42
63
|
### 🛠️ v0.7.33 版本新特性 (稳定性与问题修复)
|
|
43
64
|
- **🔍 修复密钥探测 BaseURL 解析**:`resolveBaseUrl` 现已支持从密钥 ref 反查归属提供商池,恢复在线模型连通性探测。
|
|
44
65
|
- **🛡️ 防御级联无限递归**:在跨提供商故障转移中增加递归深度防护,彻底杜绝循环级联导致的堆栈溢出。
|
|
@@ -47,7 +68,6 @@
|
|
|
47
68
|
- **⚡ 负载均衡超时锁自动释放**:`pickLeastLoaded` 算法现已检测过期连接锁,确保最小连接调度不发生偏移。
|
|
48
69
|
- **🌐 完整中文界面本地化**:为 React 设置面板补充全部 `zh` 语言包,实现标准的三语(英/俄/中)无缝对齐。
|
|
49
70
|
|
|
50
|
-
|
|
51
71
|
### 🚀 v0.7.31 版本新特性
|
|
52
72
|
- **⚡ O(1) 令牌桶累加器**:将速率限制计算升级为 O(1) 时间复杂度与零内存分配,并支持响应头自适应同步。
|
|
53
73
|
- **🛡️ 软/硬故障分级退避**:区分临时网络抖动(502/503/超时获得 10 秒平缓冷却)与硬性配额超限(指数退避倍增)。
|
|
@@ -59,7 +79,6 @@
|
|
|
59
79
|
- **🧹 30 天用量压缩**:自动清理超过 30 天的历史统计数据,保障长期运行内存上限。
|
|
60
80
|
- **✨ 乐观 UI 与快速筛选标签**:一键重置即时生效,密钥列表新增 `全部`、`就绪`、`冷却中`、`故障` 状态筛选胶囊。
|
|
61
81
|
|
|
62
|
-
|
|
63
82
|
在高吞吐量自主智能体运行、多子智能体并行执行与多轮工具调用场景下,API 极易触发上游服务商的速率限制(HTTP 429 Too Many Requests、RPM/TPM 耗尽、每日配额限制或网络抖动)。在原生的 DeepSeek Harness 中,单个密钥耗尽会导致整个智能体执行链路崩溃,破坏会话的 Replay 状态并要求人工干预。
|
|
64
83
|
|
|
65
84
|
**`dsh-key-rotation`** 基于 Cordis 微内核架构构建,提供了无缝透明的 **API 密钥池轮换、客户端预判限流(Token Bucket)与跨提供商故障转移(Failover Cascade)** 解决方案。
|
|
@@ -263,7 +282,6 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
263
282
|
- **本地化与文案 (Changed in v0.8.2)**: 插件仅注册英文源字符串 `en`;俄文/中文由 DSH 核心 locale 与 translation 插件通过 `props.t` 提供。活动语言回退:snapshot → 首个 `navigator.languages` → `en`。已移除 `settings.section` 回退与内置 `ru`/`zh` 表 (#236, #275, #277)。
|
|
264
283
|
- **死代码清理**: 移除 header-chip 迁移后残留的废弃 `mountDashboard` 函数 (#240)。
|
|
265
284
|
|
|
266
|
-
|
|
267
285
|
### 熔断器参数(v0.8.0)
|
|
268
286
|
|
|
269
287
|
| 参数 | 类型 | 默认 | 说明 |
|
package/lib/client.js
CHANGED
|
@@ -27,6 +27,10 @@ window.__ModuleLoader__.load({
|
|
|
27
27
|
// -------------------------------------------------------------- i18n
|
|
28
28
|
const en = {
|
|
29
29
|
title: 'Key Rotation',
|
|
30
|
+
'header.title': 'Key Rotation & Failover',
|
|
31
|
+
'header.sub': 'Automated multi-key round-robin rotation, 429/5xx exponential backoff cooldowns, circuit breaker failover protection, and credential security (~/.dsh/.credentials.yaml).',
|
|
32
|
+
'header.pools_badge': 'pools active',
|
|
33
|
+
'header.keys_badge': 'keys configured',
|
|
30
34
|
sectionStats: 'Pool Health & Telemetry',
|
|
31
35
|
sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
|
|
32
36
|
sectionFailover: 'Failover Triggers & Error Codes',
|
|
@@ -139,8 +143,162 @@ window.__ModuleLoader__.load({
|
|
|
139
143
|
recentFailures: 'Recent failures ({n})',
|
|
140
144
|
noActivePools: 'No active pools',
|
|
141
145
|
headerPoolsTitle: 'Key rotation pools',
|
|
142
|
-
|
|
146
|
+
pausedBudget: 'paused',
|
|
143
147
|
sortUsage: 'Sort by usage',
|
|
148
|
+
testingAllProgress: 'Testing {n}/{total}…',
|
|
149
|
+
liveEventStream: 'Live Event Stream',
|
|
150
|
+
liveEventStreamEmpty: 'No rotation events recorded',
|
|
151
|
+
eventAt: 'Time',
|
|
152
|
+
eventKey: 'Key',
|
|
153
|
+
eventReason: 'Reason',
|
|
154
|
+
eventCooldown: 'Cooldown',
|
|
155
|
+
quotaResetIn: 'Reset in {time}',
|
|
156
|
+
routingStrategyLabel: 'Routing Strategy',
|
|
157
|
+
routingStrategyRoundRobin: 'Round-Robin (default)',
|
|
158
|
+
routingStrategyLeastLoaded: 'Least-Loaded (concurrency)',
|
|
159
|
+
routingStrategyLowestLatency: 'Lowest-Latency (p95)',
|
|
160
|
+
proactiveGuardLabel: 'Proactive Rate-Limit Guard',
|
|
161
|
+
proactiveGuardHint: 'Pre-emptively pause keys when remaining quota or Retry-After header indicates limit before hitting 429',
|
|
162
|
+
selfHealingLabel: 'Auto-Unbreak Interval (min)',
|
|
163
|
+
selfHealingHint: 'Periodic free model check to revive broken keys (0 to disable)',
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
const zh = {
|
|
168
|
+
title: '密钥轮换',
|
|
169
|
+
'header.title': '密钥轮换与故障转移',
|
|
170
|
+
'header.sub': '自动化多密钥轮换、429/5xx指数退避冷却、断路器保护及凭证安全 (~/.dsh/.credentials.yaml)。',
|
|
171
|
+
'header.pools_badge': '个活跃密钥池',
|
|
172
|
+
'header.keys_badge': '个已配置密钥',
|
|
173
|
+
sectionStats: '密钥池健康与遥测',
|
|
174
|
+
sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
|
|
175
|
+
sectionFailover: '故障转移触发器与错误代码',
|
|
176
|
+
sectionFailoverDesc: '选择触发自动切换至下一个可用密钥的错误条件。',
|
|
177
|
+
sectionTiming: '时间与轮换计划',
|
|
178
|
+
sectionTimingDesc: '配置冷却持续时间和定期轮换计划窗口。',
|
|
179
|
+
sectionBackup: '备份与快照',
|
|
180
|
+
sectionBackupDesc: '导出或导入提供商密钥池,并创建完整插件配置快照。',
|
|
181
|
+
statPools: '已配置密钥池',
|
|
182
|
+
statKeys: '总密钥数',
|
|
183
|
+
statHealthy: '健康 / 就绪',
|
|
184
|
+
loadDistribution: '流量分布',
|
|
185
|
+
statRequests: '请求',
|
|
186
|
+
noTrafficYet: '暂无流量记录',
|
|
187
|
+
confirm: '确认',
|
|
188
|
+
cancel: '取消',
|
|
189
|
+
confirmResetTitle: '重置 {p} 的冷却状态?',
|
|
190
|
+
confirmResetDesc: '这将立即清除所有故障计数、冷却计时器并重置断路器。',
|
|
191
|
+
confirmRemoveProvTitle: '移除提供商 {p}?',
|
|
192
|
+
confirmRemoveProvDesc: '确定要从轮换中移除该提供商及其所有已配置密钥吗?',
|
|
193
|
+
filterAll: '全部',
|
|
194
|
+
filterReady: '就绪',
|
|
195
|
+
filterCooldown: '冷却中',
|
|
196
|
+
filterErrors: '存在错误',
|
|
197
|
+
subtitle: '按提供商 API 密钥轮换:密钥池、配额/限流错误自动故障转移、冷却与恢复。',
|
|
198
|
+
cardDesc: '按提供商 API 密钥轮换:密钥池、配额/限流错误自动故障转移、冷却与恢复。',
|
|
199
|
+
loading: '加载中…',
|
|
200
|
+
notRegistered: '未注册',
|
|
201
|
+
removeKey: '移除密钥',
|
|
202
|
+
removeProvider: '移除提供商',
|
|
203
|
+
addKey: '+ 添加密钥',
|
|
204
|
+
addKeyTitle: '添加 API 密钥',
|
|
205
|
+
noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
|
|
206
|
+
addProvider: '+ 添加提供商',
|
|
207
|
+
desc: '按提供商的 API 密钥轮换。为每个提供商列出其 API 密钥(环境变量名,存储在 DSH 凭据中)。插件按顺序通过提供商的密钥路由模型,并在配额/速率限制失败时切换到下一个。',
|
|
208
|
+
cooldown: '故障后冷却时间 (毫秒)',
|
|
209
|
+
scheduleDays: '轮换计划 (天数,0=关闭)',
|
|
210
|
+
switchCodes: '切换代码 (逗号分隔)',
|
|
211
|
+
providersTitle: '提供商与其密钥',
|
|
212
|
+
save: '保存',
|
|
213
|
+
discard: '放弃',
|
|
214
|
+
saving: '保存中…',
|
|
215
|
+
moveUp: '上移',
|
|
216
|
+
moveDown: '下移',
|
|
217
|
+
keyActive: '使用中',
|
|
218
|
+
keyReady: '就绪',
|
|
219
|
+
keyCooling: '冷却中,{s}秒',
|
|
220
|
+
keyMissing: '凭证不存在',
|
|
221
|
+
switchesNone: '暂无切换',
|
|
222
|
+
switchesSome: '切换次数: {n} · 上次: {reason}, {ago}',
|
|
223
|
+
justNow: '刚刚',
|
|
224
|
+
minutesAgo: '{n} 分钟前',
|
|
225
|
+
hoursAgo: '{n} 小时前',
|
|
226
|
+
codesTitle: '在这些故障时切换',
|
|
227
|
+
keyValuePlaceholder: '粘贴密钥,然后保存',
|
|
228
|
+
keySave: '保存密钥',
|
|
229
|
+
keySaved: '已保存',
|
|
230
|
+
keyFromEnv: '来自环境变量,此处只读',
|
|
231
|
+
keyWriteFailed: '无法存储密钥: {msg}',
|
|
232
|
+
notSecretShape: '已保存,但该值看起来不像 API 密钥 - 请检查是否有拼写错误',
|
|
233
|
+
rpmTitle: '请求/分钟: 已用 {u}, 剩余 {r}',
|
|
234
|
+
budgetLabel: '预算:',
|
|
235
|
+
exportCsv: '导出 CSV',
|
|
236
|
+
weightHint: '轮换权重:该密钥参与循环的次数 (1 = 等分)',
|
|
237
|
+
retestBroken: '重新测试',
|
|
238
|
+
retestFail: '重新测试失败 - 密钥仍然不可用',
|
|
239
|
+
snapshotExport: '快照 ⬇',
|
|
240
|
+
snapshotImport: '恢复',
|
|
241
|
+
keyHint: '该值存储在 DSH 凭据中,绝不会发送回浏览器 — 仅显示其最后 5 个字符。名称已自动生成;悬停在密钥上可查看其使用的名称。',
|
|
242
|
+
brokenKey: '已损坏 (3× 认证失败)',
|
|
243
|
+
keyExpired: '已过期',
|
|
244
|
+
keyExpiringSoon: '{n} 天后过期',
|
|
245
|
+
exportPools: '导出',
|
|
246
|
+
exportOne: '⬇',
|
|
247
|
+
usedAgo: '{ago}前使用',
|
|
248
|
+
importPools: '导入',
|
|
249
|
+
importEnv: '导入 .env',
|
|
250
|
+
resetCooldown: '重置冷却',
|
|
251
|
+
testAll: '测试所有密钥',
|
|
252
|
+
testing: '测试中…',
|
|
253
|
+
testKey: '测试',
|
|
254
|
+
testOk: '正常',
|
|
255
|
+
testFail: '失败',
|
|
256
|
+
poolExhausted: '密钥池耗尽 — 所有密钥冷却中',
|
|
257
|
+
resetting: '重置中…',
|
|
258
|
+
keyLabel: '密钥 {n}',
|
|
259
|
+
undoProvider: '提供商已移除',
|
|
260
|
+
undoKey: '密钥已移除',
|
|
261
|
+
undo: '撤销',
|
|
262
|
+
emptyTitle: '暂无密钥池',
|
|
263
|
+
emptyDesc: '添加提供商及至少一个 API 密钥名称以开始轮换。',
|
|
264
|
+
errorTitle: '无法加载设置',
|
|
265
|
+
retry: '重试',
|
|
266
|
+
unavailableTitle: '设置桥接不可用',
|
|
267
|
+
unavailableDesc: '宿主未暴露 dsh-key-rotation 设置命名空间。请重新打开设置或检查插件是否已启用。',
|
|
268
|
+
modalClose: '关闭对话框',
|
|
269
|
+
dialogLabel: '确认',
|
|
270
|
+
loadChartAria: '密钥流量分布',
|
|
271
|
+
bulkCooldownPlaceholder: '批量冷却时间 (毫秒)',
|
|
272
|
+
bulkApply: '应用到所选项',
|
|
273
|
+
bulkRemove: '删除所选项',
|
|
274
|
+
confirmBulkRemoveTitle: '删除 {n} 个提供商?',
|
|
275
|
+
confirmBulkRemoveDesc: '所选提供商及其所有密钥将从轮换中移除。操作后可立即撤销一次。',
|
|
276
|
+
selectProvider: '选择提供商 {p}',
|
|
277
|
+
activeBadge: '活跃',
|
|
278
|
+
healthScoreTitle: '健康评分',
|
|
279
|
+
totalRequestsTitle: '总请求数',
|
|
280
|
+
exportProvider: '导出此提供商',
|
|
281
|
+
recentFailures: '近期故障 ({n})',
|
|
282
|
+
noActivePools: '无活跃密钥池',
|
|
283
|
+
headerPoolsTitle: '密钥轮换池',
|
|
284
|
+
pausedBudget: '已暂停',
|
|
285
|
+
sortUsage: '按使用量排序',
|
|
286
|
+
testingAllProgress: '正在测试 {n}/{total}…',
|
|
287
|
+
liveEventStream: '实时事件流',
|
|
288
|
+
liveEventStreamEmpty: '暂无轮换事件记录',
|
|
289
|
+
eventAt: '时间',
|
|
290
|
+
eventKey: '密钥',
|
|
291
|
+
eventReason: '原因',
|
|
292
|
+
eventCooldown: '冷却',
|
|
293
|
+
quotaResetIn: '{time} 后重置',
|
|
294
|
+
routingStrategyLabel: '路由策略',
|
|
295
|
+
routingStrategyRoundRobin: '轮询 (默认)',
|
|
296
|
+
routingStrategyLeastLoaded: '最低负载 (并发)',
|
|
297
|
+
routingStrategyLowestLatency: '最低延迟 (p95)',
|
|
298
|
+
proactiveGuardLabel: '前瞻性限流防护',
|
|
299
|
+
proactiveGuardHint: '当响应中的剩余配额或 Retry-After 头提示即将超限时,预先冷却密钥以防触发 429',
|
|
300
|
+
selfHealingLabel: '自愈检查间隔 (分钟)',
|
|
301
|
+
selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
|
|
144
302
|
};
|
|
145
303
|
|
|
146
304
|
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
@@ -244,7 +402,10 @@ window.__ModuleLoader__.load({
|
|
|
244
402
|
// на поле значения, поэтому имя занимает свою строку, а служебная строка
|
|
245
403
|
// под ним ужимается сама.
|
|
246
404
|
const CARD_CSS = [
|
|
247
|
-
'.krot{display:flex;flex-direction:column;gap:
|
|
405
|
+
'.krot{display:flex;flex-direction:column;gap:20px;max-width:960px;padding:6px 0 24px;box-sizing:border-box}',
|
|
406
|
+
'.krot-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
|
|
407
|
+
'.krot-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
408
|
+
'.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
|
|
248
409
|
'.krot p{margin:0}',
|
|
249
410
|
'.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
|
|
250
411
|
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);display:flex;align-items:center;gap:8px}',
|
|
@@ -308,6 +469,9 @@ window.__ModuleLoader__.load({
|
|
|
308
469
|
'.krot-pill-err{border-color:rgba(239,68,68,0.3);color:var(--dsw-alias-state-error-primary)}',
|
|
309
470
|
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
310
471
|
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
472
|
+
'.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
473
|
+
'.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
474
|
+
'.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
311
475
|
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
312
476
|
'.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
313
477
|
'.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
|
|
@@ -493,6 +657,7 @@ window.__ModuleLoader__.load({
|
|
|
493
657
|
const [testing, setTesting] = React.useState('');
|
|
494
658
|
const [testResult, setTestResult] = React.useState({});
|
|
495
659
|
const [testAllProvider, setTestAllProvider] = React.useState('');
|
|
660
|
+
const [testingProgress, setTestingProgress] = React.useState({});
|
|
496
661
|
const [secretDraft, setSecretDraft] = React.useState({});
|
|
497
662
|
const [secretError, setSecretError] = React.useState('');
|
|
498
663
|
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
@@ -1117,6 +1282,24 @@ window.__ModuleLoader__.load({
|
|
|
1117
1282
|
return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
1118
1283
|
})(),
|
|
1119
1284
|
(() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: t('totalRequestsTitle'), style: { flex: 'none' } }, String(tot)) : null; })(),
|
|
1285
|
+
(() => {
|
|
1286
|
+
const q = status.quota || (providerStatus && providerStatus.quota);
|
|
1287
|
+
let nearestReset = null;
|
|
1288
|
+
if (q && typeof q === 'object') {
|
|
1289
|
+
for (const k of (entry.keys || [])) {
|
|
1290
|
+
const entryQ = q[k];
|
|
1291
|
+
if (entryQ && typeof entryQ.reset === 'number' && entryQ.reset > Date.now()) {
|
|
1292
|
+
if (!nearestReset || entryQ.reset < nearestReset) nearestReset = entryQ.reset;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (!nearestReset) return null;
|
|
1297
|
+
const diffMs = nearestReset - Date.now();
|
|
1298
|
+
if (diffMs <= 0) return null;
|
|
1299
|
+
const mins = Math.ceil(diffMs / 60000);
|
|
1300
|
+
const timeStr = mins > 60 ? Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm' : mins + 'm';
|
|
1301
|
+
return h('span', { className: 'krot-badge krot-badge-warn', title: 'Quota window reset' }, t('quotaResetIn').replace('{time}', timeStr));
|
|
1302
|
+
})(),
|
|
1120
1303
|
btn('✕', () => setConfirmModal({
|
|
1121
1304
|
title: t('confirmRemoveProvTitle').replace('{p}', entry.provider),
|
|
1122
1305
|
desc: t('confirmRemoveProvDesc'),
|
|
@@ -1137,7 +1320,26 @@ window.__ModuleLoader__.load({
|
|
|
1137
1320
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
1138
1321
|
// #224: 7-day switches per day (client-side, from the same events)
|
|
1139
1322
|
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-state-info-primary, var(--dsw-alias-state-warning-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
|
|
1140
|
-
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', {
|
|
1323
|
+
(providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { className: 'krot-event-stream' },
|
|
1324
|
+
h('summary', { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '12px' } },
|
|
1325
|
+
h('span', null, '📜 ' + t('liveEventStream')),
|
|
1326
|
+
h('span', { className: 'krot-badge' }, String(providerStatus.events.length))
|
|
1327
|
+
),
|
|
1328
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' } },
|
|
1329
|
+
providerStatus.events.slice().reverse().map((ev, i) => {
|
|
1330
|
+
const isHeal = ev.type === 'heal' || ev.reason === 'auto-unbreak' || ev.reason === 'self-heal';
|
|
1331
|
+
const isBad = ev.code === 'AUTH' || ev.reason === 'AUTH' || ev.type === 'broken';
|
|
1332
|
+
const badgeClass = isHeal ? 'krot-badge-ok' : isBad ? 'krot-badge-bad' : 'krot-badge-warn';
|
|
1333
|
+
const timeStr = ev.at ? new Date(ev.at).toLocaleTimeString() : '';
|
|
1334
|
+
return h('div', { key: i, className: 'krot-event-row' },
|
|
1335
|
+
h('span', { className: 'krot-event-time' }, timeStr),
|
|
1336
|
+
h('span', { className: 'krot-tail' }, ev.ref || 'pool'),
|
|
1337
|
+
h('span', { className: 'krot-badge ' + badgeClass }, String(ev.reason || ev.code || ev.type)),
|
|
1338
|
+
ev.cooldownMs > 0 ? h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, 'cd: ' + Math.round(ev.cooldownMs / 1000) + 's') : null
|
|
1339
|
+
);
|
|
1340
|
+
})
|
|
1341
|
+
)
|
|
1342
|
+
) : null),
|
|
1141
1343
|
btn(t('resetCooldown'), () => setConfirmModal({
|
|
1142
1344
|
title: t('confirmResetTitle').replace('{p}', entry.provider),
|
|
1143
1345
|
desc: t('confirmResetDesc'),
|
|
@@ -1145,7 +1347,7 @@ window.__ModuleLoader__.load({
|
|
|
1145
1347
|
danger: false,
|
|
1146
1348
|
onConfirm: () => doReset(entry.provider),
|
|
1147
1349
|
}), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
1148
|
-
btn(testAllProvider === entry.provider ? t('testing') : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
|
|
1350
|
+
btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
|
|
1149
1351
|
exportCsv,
|
|
1150
1352
|
),
|
|
1151
1353
|
);
|
|
@@ -1269,6 +1471,26 @@ window.__ModuleLoader__.load({
|
|
|
1269
1471
|
h('div', { className: 'krot-grid-2' },
|
|
1270
1472
|
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
1271
1473
|
field(t('scheduleDays'), textInput(String(val.rotationScheduleDays ?? 0), (v) => setField((cur) => ({ ...cur, rotationScheduleDays: Number(v) || 0 }))))
|
|
1474
|
+
),
|
|
1475
|
+
h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
|
|
1476
|
+
field(t('routingStrategyLabel'), h('select', {
|
|
1477
|
+
className: 'krot-in',
|
|
1478
|
+
value: val.routingStrategy ?? 'round-robin',
|
|
1479
|
+
onChange: (e) => setField((cur) => ({ ...cur, routingStrategy: e.target.value })),
|
|
1480
|
+
}, [
|
|
1481
|
+
h('option', { key: 'round-robin', value: 'round-robin' }, t('routingStrategyRoundRobin')),
|
|
1482
|
+
h('option', { key: 'least-loaded', value: 'least-loaded' }, t('routingStrategyLeastLoaded')),
|
|
1483
|
+
h('option', { key: 'lowest-latency', value: 'lowest-latency' }, t('routingStrategyLowestLatency')),
|
|
1484
|
+
])),
|
|
1485
|
+
field(t('selfHealingLabel'), textInput(String(val.selfHealingIntervalMinutes ?? 30), (v) => setField((cur) => ({ ...cur, selfHealingIntervalMinutes: Number(v) || 0 })))),
|
|
1486
|
+
field(t('proactiveGuardLabel'), h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', height: '36px', cursor: 'pointer' } }, [
|
|
1487
|
+
h('input', {
|
|
1488
|
+
type: 'checkbox',
|
|
1489
|
+
checked: val.proactiveRateLimitGuard ?? true,
|
|
1490
|
+
onChange: (e) => setField((cur) => ({ ...cur, proactiveRateLimitGuard: e.target.checked })),
|
|
1491
|
+
}),
|
|
1492
|
+
h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
|
|
1493
|
+
]))
|
|
1272
1494
|
)
|
|
1273
1495
|
);
|
|
1274
1496
|
|
|
@@ -1359,8 +1581,25 @@ window.__ModuleLoader__.load({
|
|
|
1359
1581
|
)
|
|
1360
1582
|
) : null;
|
|
1361
1583
|
|
|
1584
|
+
const headerSection = h('div', { className: 'krot-header' },
|
|
1585
|
+
h('div', { className: 'krot-page-title' },
|
|
1586
|
+
'🔄 ' + t('header.title'),
|
|
1587
|
+
h('span', { className: 'krot-badge ' + (totalPoolsCount > 0 ? 'krot-badge-ok' : 'krot-badge-warn') },
|
|
1588
|
+
totalPoolsCount > 0 ? (totalPoolsCount + ' ' + t('header.pools_badge')) : t('noActivePools')
|
|
1589
|
+
),
|
|
1590
|
+
h('span', { className: 'krot-badge ' + (totalKeysCount > 0 ? 'krot-badge-ok' : 'krot-badge-warn') },
|
|
1591
|
+
totalKeysCount + ' ' + t('header.keys_badge')
|
|
1592
|
+
),
|
|
1593
|
+
h('span', { className: 'krot-badge ' + (healthyKeysCount === totalKeysCount && totalKeysCount > 0 ? 'krot-badge-ok' : 'krot-badge-warn') },
|
|
1594
|
+
healthyKeysCount + '/' + totalKeysCount + ' ' + t('keyReady')
|
|
1595
|
+
)
|
|
1596
|
+
),
|
|
1597
|
+
h('div', { className: 'krot-page-sub' }, t('header.sub'))
|
|
1598
|
+
);
|
|
1599
|
+
|
|
1362
1600
|
return h('div', { className: 'krot' },
|
|
1363
1601
|
confirmModalEl,
|
|
1602
|
+
headerSection,
|
|
1364
1603
|
statsSection,
|
|
1365
1604
|
poolsSection,
|
|
1366
1605
|
failoverSection,
|
|
@@ -1451,7 +1690,7 @@ window.__ModuleLoader__.load({
|
|
|
1451
1690
|
}
|
|
1452
1691
|
|
|
1453
1692
|
function apply(ctx) {
|
|
1454
|
-
ctx.effect(() => ctx.locale.register(NS, { en }), 'dsh-key-rotation: dictionaries');
|
|
1693
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
1455
1694
|
// Header chip (#201): status dot in the session header utilities slot,
|
|
1456
1695
|
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
1457
1696
|
ctx.effect(() => {
|
package/lib/concurrency.js
CHANGED
|
@@ -40,6 +40,15 @@ export class ConcurrencyTracker {
|
|
|
40
40
|
e.lastAcquired = now;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
getActive(ref, now) {
|
|
44
|
+
now = now || Date.now();
|
|
45
|
+
if (!ref) return 0;
|
|
46
|
+
const e = this._inFlight.get(ref);
|
|
47
|
+
if (!e) return 0;
|
|
48
|
+
if (now - e.lastAcquired > this._staleMs) return 0;
|
|
49
|
+
return e.count;
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
snapshot() {
|
|
44
53
|
const out = {};
|
|
45
54
|
for (const [k, v] of this._inFlight) out[k] = { count: v.count };
|
package/lib/heal.js
CHANGED
|
@@ -33,3 +33,50 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
|
33
33
|
}
|
|
34
34
|
return healed;
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Background auto-recovery for keys in brokenUntil status.
|
|
39
|
+
* Tests broken keys with a lightweight free probe (e.g. probeModels).
|
|
40
|
+
* If the probe succeeds, clears broken and failure states and records a 'heal' event.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array} pools
|
|
43
|
+
* @param {Function} probeFn async (ref) => { ok: boolean, code?: string, status?: number }
|
|
44
|
+
* @param {number} [now=Date.now()]
|
|
45
|
+
* @returns {Promise<Array<{ ref: string, poolBase: string, ok: boolean }>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
48
|
+
if (!Array.isArray(pools) || pools.length === 0 || typeof probeFn !== 'function') return [];
|
|
49
|
+
const results = [];
|
|
50
|
+
for (const pool of pools) {
|
|
51
|
+
if (!pool || !pool.state || !pool.base) continue;
|
|
52
|
+
const bu = pool.state.brokenUntil;
|
|
53
|
+
if (!bu || bu.size === 0) continue;
|
|
54
|
+
const brokenRefs = [];
|
|
55
|
+
for (const [ref, until] of bu.entries()) {
|
|
56
|
+
if (Number.isFinite(until) && until > now) {
|
|
57
|
+
brokenRefs.push(ref);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const ref of brokenRefs) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await probeFn(ref);
|
|
63
|
+
if (res && res.ok) {
|
|
64
|
+
bu.delete(ref);
|
|
65
|
+
pool.state.failedUntil?.delete(ref);
|
|
66
|
+
pool.state.failCounts?.delete(ref);
|
|
67
|
+
pool.state.authFailCounts?.delete(ref);
|
|
68
|
+
if (Array.isArray(pool.state.events)) {
|
|
69
|
+
pool.state.events.push({ at: now, ref, reason: 'auto-unbreak', cooldownMs: 0, type: 'heal' });
|
|
70
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
71
|
+
}
|
|
72
|
+
results.push({ ref, poolBase: pool.base, ok: true });
|
|
73
|
+
} else {
|
|
74
|
+
results.push({ ref, poolBase: pool.base, ok: false });
|
|
75
|
+
}
|
|
76
|
+
} catch (_) {
|
|
77
|
+
results.push({ ref, poolBase: pool.base, ok: false });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|