@goodandready/dsh-key-rotation 0.8.8 → 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 +219 -4
- package/lib/concurrency.js +9 -0
- package/lib/heal.js +47 -0
- package/lib/index.js +46 -8
- package/lib/pool.js +104 -21
- package/lib/rotate.js +127 -107
- package/lib/routes-ops.js +2 -0
- 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
|
@@ -143,8 +143,162 @@ window.__ModuleLoader__.load({
|
|
|
143
143
|
recentFailures: 'Recent failures ({n})',
|
|
144
144
|
noActivePools: 'No active pools',
|
|
145
145
|
headerPoolsTitle: 'Key rotation pools',
|
|
146
|
-
|
|
146
|
+
pausedBudget: 'paused',
|
|
147
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 为禁用)',
|
|
148
302
|
};
|
|
149
303
|
|
|
150
304
|
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
@@ -315,6 +469,9 @@ window.__ModuleLoader__.load({
|
|
|
315
469
|
'.krot-pill-err{border-color:rgba(239,68,68,0.3);color:var(--dsw-alias-state-error-primary)}',
|
|
316
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}',
|
|
317
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}',
|
|
318
475
|
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
319
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}',
|
|
320
477
|
'.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
|
|
@@ -500,6 +657,7 @@ window.__ModuleLoader__.load({
|
|
|
500
657
|
const [testing, setTesting] = React.useState('');
|
|
501
658
|
const [testResult, setTestResult] = React.useState({});
|
|
502
659
|
const [testAllProvider, setTestAllProvider] = React.useState('');
|
|
660
|
+
const [testingProgress, setTestingProgress] = React.useState({});
|
|
503
661
|
const [secretDraft, setSecretDraft] = React.useState({});
|
|
504
662
|
const [secretError, setSecretError] = React.useState('');
|
|
505
663
|
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
@@ -1124,6 +1282,24 @@ window.__ModuleLoader__.load({
|
|
|
1124
1282
|
return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
|
|
1125
1283
|
})(),
|
|
1126
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
|
+
})(),
|
|
1127
1303
|
btn('✕', () => setConfirmModal({
|
|
1128
1304
|
title: t('confirmRemoveProvTitle').replace('{p}', entry.provider),
|
|
1129
1305
|
desc: t('confirmRemoveProvDesc'),
|
|
@@ -1144,7 +1320,26 @@ window.__ModuleLoader__.load({
|
|
|
1144
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),
|
|
1145
1321
|
// #224: 7-day switches per day (client-side, from the same events)
|
|
1146
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),
|
|
1147
|
-
(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),
|
|
1148
1343
|
btn(t('resetCooldown'), () => setConfirmModal({
|
|
1149
1344
|
title: t('confirmResetTitle').replace('{p}', entry.provider),
|
|
1150
1345
|
desc: t('confirmResetDesc'),
|
|
@@ -1152,7 +1347,7 @@ window.__ModuleLoader__.load({
|
|
|
1152
1347
|
danger: false,
|
|
1153
1348
|
onConfirm: () => doReset(entry.provider),
|
|
1154
1349
|
}), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
|
|
1155
|
-
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') }),
|
|
1156
1351
|
exportCsv,
|
|
1157
1352
|
),
|
|
1158
1353
|
);
|
|
@@ -1276,6 +1471,26 @@ window.__ModuleLoader__.load({
|
|
|
1276
1471
|
h('div', { className: 'krot-grid-2' },
|
|
1277
1472
|
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
1278
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
|
+
]))
|
|
1279
1494
|
)
|
|
1280
1495
|
);
|
|
1281
1496
|
|
|
@@ -1475,7 +1690,7 @@ window.__ModuleLoader__.load({
|
|
|
1475
1690
|
}
|
|
1476
1691
|
|
|
1477
1692
|
function apply(ctx) {
|
|
1478
|
-
ctx.effect(() => ctx.locale.register(NS, { en }), 'dsh-key-rotation: dictionaries');
|
|
1693
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
1479
1694
|
// Header chip (#201): status dot in the session header utilities slot,
|
|
1480
1695
|
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
1481
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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -27,8 +27,9 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
27
27
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
28
28
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
29
29
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
30
|
+
import { sortAttemptList } from './pool.js';
|
|
30
31
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
31
|
-
import { healIdleCooldowns } from './heal.js';
|
|
32
|
+
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
32
33
|
import { LatencyHistogram } from './histogram.js';
|
|
33
34
|
import { pickCascadeFallback } from './cascade.js';
|
|
34
35
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
@@ -199,6 +200,9 @@ export const Config = Schema.object({
|
|
|
199
200
|
hour: Schema.number().default(0),
|
|
200
201
|
}),
|
|
201
202
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
203
|
+
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
204
|
+
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
205
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']).default('round-robin'),
|
|
202
206
|
rpmLimit: Schema.number().default(0),
|
|
203
207
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
204
208
|
expiryWarnDays: Schema.number().default(7),
|
|
@@ -230,6 +234,8 @@ export const Config = Schema.object({
|
|
|
230
234
|
})).default({}),
|
|
231
235
|
cooldownMs: Schema.number(),
|
|
232
236
|
maxCooldownMs: Schema.number(),
|
|
237
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']),
|
|
238
|
+
proactiveRateLimitGuard: Schema.boolean(),
|
|
233
239
|
})).default([...DEFAULT_PROVIDERS]),
|
|
234
240
|
});
|
|
235
241
|
|
|
@@ -339,6 +345,29 @@ export function apply(ctx, config = {}) {
|
|
|
339
345
|
if (snap) statePersistence.save(snap);
|
|
340
346
|
}
|
|
341
347
|
|
|
348
|
+
// Auto-healing / auto-unbreak for broken keys (#303)
|
|
349
|
+
ctx.effect(() => {
|
|
350
|
+
const cfg = getConfig();
|
|
351
|
+
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
352
|
+
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
353
|
+
const intervalMs = intervalMin * 60 * 1000;
|
|
354
|
+
const timer = setInterval(async () => {
|
|
355
|
+
try {
|
|
356
|
+
const c = getConfig();
|
|
357
|
+
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
358
|
+
const { pools } = buildRuntime();
|
|
359
|
+
const runner = ensureSandboxRunner(ctx);
|
|
360
|
+
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
361
|
+
let val = (await ctx.credentials?.resolve?.(ref))?.value;
|
|
362
|
+
if (!val) return { ok: false };
|
|
363
|
+
return runner.probeModels(ref, val);
|
|
364
|
+
});
|
|
365
|
+
} catch (_) {}
|
|
366
|
+
}, intervalMs);
|
|
367
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
368
|
+
return () => clearInterval(timer);
|
|
369
|
+
}, 'dsh-key-rotation: auto-unbreak');
|
|
370
|
+
|
|
342
371
|
ctx.effect(() => {
|
|
343
372
|
const timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
|
|
344
373
|
if (typeof timer.unref === 'function') timer.unref();
|
|
@@ -372,9 +401,10 @@ export function apply(ctx, config = {}) {
|
|
|
372
401
|
}
|
|
373
402
|
}
|
|
374
403
|
}
|
|
375
|
-
const n = sweepExpired(poolState, now);
|
|
376
|
-
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
377
404
|
const runtime = buildRuntime();
|
|
405
|
+
const allActiveRefs = Array.from(runtime.poolByRef.keys());
|
|
406
|
+
const n = sweepExpired(poolState, now, allActiveRefs);
|
|
407
|
+
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
378
408
|
for (const pool of runtime.poolByRef.values()) {
|
|
379
409
|
compactUsage(pool, 30, now);
|
|
380
410
|
}
|
|
@@ -564,7 +594,7 @@ export function apply(ctx, config = {}) {
|
|
|
564
594
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
565
595
|
return undefined;
|
|
566
596
|
};
|
|
567
|
-
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
597
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) => {
|
|
568
598
|
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
569
599
|
if (refs.length === 0) return null;
|
|
570
600
|
const w = Array.isArray(weights) ? weights : [];
|
|
@@ -582,7 +612,7 @@ export function apply(ctx, config = {}) {
|
|
|
582
612
|
}
|
|
583
613
|
return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
584
614
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
585
|
-
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
|
|
615
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit, routingStrategy: poolStrategy, proactiveRateLimitGuard: poolGuard };
|
|
586
616
|
};
|
|
587
617
|
for (const p of cfg.providers ?? []) {
|
|
588
618
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
@@ -656,7 +686,10 @@ export function apply(ctx, config = {}) {
|
|
|
656
686
|
}
|
|
657
687
|
}
|
|
658
688
|
}
|
|
659
|
-
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
689
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
690
|
+
proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
|
|
691
|
+
selfHealingIntervalMinutes: cfg.selfHealingIntervalMinutes ?? 30,
|
|
692
|
+
routingStrategy: cfg.routingStrategy ?? 'round-robin', switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
|
|
660
693
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
661
694
|
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
662
695
|
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
@@ -684,8 +717,12 @@ export function apply(ctx, config = {}) {
|
|
|
684
717
|
const pool = poolByRef.get(ref);
|
|
685
718
|
if (!pool) return original(ref);
|
|
686
719
|
const now = Date.now();
|
|
687
|
-
const
|
|
688
|
-
|
|
720
|
+
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
721
|
+
let list = pool.weightedRefs ?? pool.refs;
|
|
722
|
+
if (strat === 'lowest-latency' || strat === 'least-loaded') {
|
|
723
|
+
list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
|
|
724
|
+
}
|
|
725
|
+
const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
|
|
689
726
|
for (let i = 0; i < list.length; i++) {
|
|
690
727
|
const index = (start + i) % list.length;
|
|
691
728
|
const candidate = list[index];
|
|
@@ -805,6 +842,7 @@ export function apply(ctx, config = {}) {
|
|
|
805
842
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
806
843
|
notifyExhaustion,
|
|
807
844
|
recordLatency,
|
|
845
|
+
latencyHistogram,
|
|
808
846
|
concurrencyTracker,
|
|
809
847
|
MARKER,
|
|
810
848
|
finishError,
|
package/lib/pool.js
CHANGED
|
@@ -184,9 +184,10 @@ export function envValue(ref) {
|
|
|
184
184
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
|
|
188
|
-
export function sweepExpired(poolState, now = Date.now()) {
|
|
187
|
+
/** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
|
|
188
|
+
export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
189
189
|
let cleared = 0;
|
|
190
|
+
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
190
191
|
for (const st of poolState.values()) {
|
|
191
192
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
192
193
|
if (until <= now) {
|
|
@@ -195,6 +196,16 @@ export function sweepExpired(poolState, now = Date.now()) {
|
|
|
195
196
|
cleared++;
|
|
196
197
|
}
|
|
197
198
|
}
|
|
199
|
+
// Prune stale entries for keys no longer in active configuration
|
|
200
|
+
if (activeSet) {
|
|
201
|
+
for (const map of [st.failedUntil, st.failCounts, st.authFailCounts, st.brokenUntil, st.costPerKey, st.lastUsedAt, st.usageCounts, st.byModel, st.usageDays, st.quotaWindows, st.rpmWindows, st.probedAt]) {
|
|
202
|
+
if (map && typeof map.keys === 'function') {
|
|
203
|
+
for (const k of [...map.keys()]) {
|
|
204
|
+
if (!activeSet.has(k)) map.delete(k);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
198
209
|
}
|
|
199
210
|
return cleared;
|
|
200
211
|
}
|
|
@@ -248,33 +259,105 @@ export function computeHealthScore(state) {
|
|
|
248
259
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
249
260
|
export function extractRateLimit(headers) {
|
|
250
261
|
if (!headers || typeof headers !== 'object') return null;
|
|
251
|
-
let remaining, limit, reset;
|
|
262
|
+
let remaining, limit, reset, retryAfter;
|
|
252
263
|
for (const k in headers) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
const lower = k.toLowerCase();
|
|
265
|
+
const v = headers[k];
|
|
266
|
+
if (v == null) continue;
|
|
267
|
+
if (lower === 'retry-after') {
|
|
268
|
+
const num = Number(v);
|
|
269
|
+
if (Number.isFinite(num) && num >= 0) {
|
|
270
|
+
retryAfter = Math.ceil(num);
|
|
271
|
+
} else {
|
|
272
|
+
const parsed = Date.parse(v);
|
|
273
|
+
if (!Number.isNaN(parsed)) {
|
|
274
|
+
retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (
|
|
278
|
+
lower === 'x-ratelimit-remaining' ||
|
|
279
|
+
lower === 'x-ratelimit-remaining-requests' ||
|
|
280
|
+
lower === 'anthropic-ratelimit-requests-remaining' ||
|
|
281
|
+
lower === 'openai-ratelimit-remaining-requests' ||
|
|
282
|
+
lower === 'ratelimit-remaining'
|
|
283
|
+
) {
|
|
284
|
+
const n = Number(String(v));
|
|
285
|
+
if (Number.isFinite(n)) remaining = n;
|
|
286
|
+
} else if (
|
|
287
|
+
(lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
|
|
288
|
+
remaining === undefined
|
|
289
|
+
) {
|
|
290
|
+
const n = Number(String(v));
|
|
291
|
+
if (Number.isFinite(n)) remaining = n;
|
|
292
|
+
} else if (
|
|
293
|
+
lower === 'x-ratelimit-limit' ||
|
|
294
|
+
lower === 'x-ratelimit-limit-requests' ||
|
|
295
|
+
lower === 'anthropic-ratelimit-requests-limit' ||
|
|
296
|
+
lower === 'openai-ratelimit-limit-requests' ||
|
|
297
|
+
lower === 'ratelimit-limit'
|
|
298
|
+
) {
|
|
299
|
+
const n = Number(String(v));
|
|
300
|
+
if (Number.isFinite(n)) limit = n;
|
|
301
|
+
} else if (
|
|
302
|
+
lower === 'x-ratelimit-reset' ||
|
|
303
|
+
lower === 'x-ratelimit-reset-requests' ||
|
|
304
|
+
lower === 'anthropic-ratelimit-requests-reset' ||
|
|
305
|
+
lower === 'ratelimit-reset'
|
|
306
|
+
) {
|
|
307
|
+
const n = Number(String(v));
|
|
308
|
+
if (Number.isFinite(n)) {
|
|
309
|
+
reset = n;
|
|
310
|
+
} else {
|
|
311
|
+
const parsed = Date.parse(v);
|
|
312
|
+
if (!Number.isNaN(parsed)) reset = parsed;
|
|
265
313
|
}
|
|
266
314
|
}
|
|
267
315
|
}
|
|
268
|
-
if (remaining === undefined && limit === undefined) return null;
|
|
269
|
-
|
|
316
|
+
if (remaining === undefined && limit === undefined && retryAfter === undefined && reset === undefined) return null;
|
|
317
|
+
const out = { remaining, limit, reset };
|
|
318
|
+
if (retryAfter !== undefined) out.retryAfter = retryAfter;
|
|
319
|
+
return out;
|
|
270
320
|
}
|
|
271
321
|
|
|
272
|
-
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
|
|
322
|
+
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1) or retry-after is active. */
|
|
273
323
|
export function isRateLimited(rate, threshold = 0.1) {
|
|
274
324
|
if (!rate) return false;
|
|
275
|
-
if (rate.
|
|
276
|
-
if (rate.
|
|
277
|
-
|
|
325
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) return true;
|
|
326
|
+
if (rate.remaining !== undefined && rate.remaining <= 1) return true;
|
|
327
|
+
if (rate.limit && rate.limit > 0 && rate.remaining !== undefined) {
|
|
328
|
+
return rate.remaining < rate.limit * threshold;
|
|
329
|
+
}
|
|
330
|
+
if (rate.remaining !== undefined) return rate.remaining <= 0;
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Sort or prioritize an attempt list of refs according to routing strategy.
|
|
336
|
+
* Strategies:
|
|
337
|
+
* - 'round-robin': preserves order
|
|
338
|
+
* - 'least-loaded': keys with lowest active concurrency count first
|
|
339
|
+
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
340
|
+
*/
|
|
341
|
+
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
342
|
+
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
343
|
+
const list = refs.slice();
|
|
344
|
+
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
345
|
+
return list.sort((a, b) => {
|
|
346
|
+
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
347
|
+
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
348
|
+
return ca - cb;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
352
|
+
return list.sort((a, b) => {
|
|
353
|
+
const sa = deps.latencyHistogram.snapshot(a);
|
|
354
|
+
const sb = deps.latencyHistogram.snapshot(b);
|
|
355
|
+
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
356
|
+
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
357
|
+
return la - lb;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return list;
|
|
278
361
|
}
|
|
279
362
|
|
|
280
363
|
/**
|
package/lib/rotate.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
extractRateLimit,
|
|
10
10
|
isRateLimited,
|
|
11
11
|
formatExhaustionMessage,
|
|
12
|
+
sortAttemptList,
|
|
12
13
|
} from './pool.js';
|
|
13
14
|
import { pickCascadeFallback } from './cascade.js';
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ export function createRotate(deps) {
|
|
|
25
26
|
notifySwitch,
|
|
26
27
|
notifyExhaustion,
|
|
27
28
|
recordLatency,
|
|
29
|
+
latencyHistogram,
|
|
28
30
|
concurrencyTracker,
|
|
29
31
|
MARKER,
|
|
30
32
|
finishError,
|
|
@@ -42,7 +44,11 @@ export function createRotate(deps) {
|
|
|
42
44
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
43
45
|
setRotateStartMs(reqStore.startMs);
|
|
44
46
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
45
|
-
|
|
47
|
+
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
48
|
+
if (strategy !== 'round-robin') {
|
|
49
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
|
|
50
|
+
}
|
|
51
|
+
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
46
52
|
// #193: prefer least-loaded key within limit
|
|
47
53
|
const available = attemptList.filter((r) => {
|
|
48
54
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
@@ -115,122 +121,136 @@ export function createRotate(deps) {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
118
|
-
|
|
124
|
+
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
119
125
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (chunk && chunk.type === 'finish') {
|
|
129
|
-
const kind = chunk.reason?.kind;
|
|
130
|
-
const failure = chunk.reason?.failure;
|
|
131
|
-
const code = failure?.code;
|
|
132
|
-
const message = failure?.message ?? '';
|
|
133
|
-
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
134
|
-
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
135
|
-
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
136
|
-
if (switchable) {
|
|
137
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
138
|
-
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
139
|
-
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
140
|
-
pool.state.lastSwitchAt = now();
|
|
141
|
-
lastFailure = chunk;
|
|
142
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
143
|
-
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
144
|
-
if (switchNotify && activeRef) {
|
|
145
|
-
notifySwitch(runtime0, pool, {
|
|
146
|
-
provider: options.provider,
|
|
147
|
-
from: activeRef,
|
|
148
|
-
code: String(code ?? 'UNKNOWN'),
|
|
149
|
-
at: pool.state.lastSwitchAt,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
switching = true;
|
|
153
|
-
break;
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of inner) {
|
|
128
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
129
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
130
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
131
|
+
yielded = true;
|
|
132
|
+
yield chunk;
|
|
133
|
+
continue;
|
|
154
134
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
pool.state.
|
|
135
|
+
if (chunk && chunk.type === 'finish') {
|
|
136
|
+
const kind = chunk.reason?.kind;
|
|
137
|
+
const failure = chunk.reason?.failure;
|
|
138
|
+
const code = failure?.code;
|
|
139
|
+
const message = failure?.message ?? '';
|
|
140
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
141
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
142
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
143
|
+
if (switchable) {
|
|
144
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
145
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
146
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
147
|
+
pool.state.lastSwitchAt = now();
|
|
148
|
+
lastFailure = chunk;
|
|
149
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
150
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
151
|
+
if (switchNotify && activeRef) {
|
|
152
|
+
notifySwitch(runtime0, pool, {
|
|
153
|
+
provider: options.provider,
|
|
154
|
+
from: activeRef,
|
|
155
|
+
code: String(code ?? 'UNKNOWN'),
|
|
156
|
+
at: pool.state.lastSwitchAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
switching = true;
|
|
160
|
+
break;
|
|
167
161
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
182
|
-
}
|
|
183
|
-
// Proactive rate-limit (#115): if response headers say this key is near
|
|
184
|
-
// its quota, cool it down so the NEXT request starts on a different key.
|
|
185
|
-
// We do NOT re-run this (already successful) request — that would double-send.
|
|
186
|
-
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
187
|
-
if (rate && activeRef) {
|
|
188
|
-
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
189
|
-
const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
|
|
190
|
-
recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
|
|
191
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
192
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
162
|
+
// cost tracking if provider returns usage.cost
|
|
163
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
164
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
165
|
+
const c = Number(chunk.usage.cost);
|
|
166
|
+
if (!isNaN(c)) {
|
|
167
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
168
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
169
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
170
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
171
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
172
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
173
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
174
|
+
}
|
|
193
175
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
176
|
+
// Usage by day (#119)
|
|
177
|
+
if (activeRef) {
|
|
178
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
179
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
180
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
181
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
182
|
+
}
|
|
183
|
+
// Per-model request detail (#121)
|
|
184
|
+
if (activeRef && options.model) {
|
|
185
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
186
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
187
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
188
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
191
|
+
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
192
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
193
|
+
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
194
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
195
|
+
if (guardEnabled && rate && activeRef) {
|
|
196
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
197
|
+
let cool;
|
|
198
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
199
|
+
cool = rate.retryAfter * 1000;
|
|
200
|
+
} else if (rate.reset && rate.reset > now()) {
|
|
201
|
+
cool = rate.reset - now();
|
|
202
|
+
} else {
|
|
203
|
+
cool = pool.cooldownMs ?? cooldownMs;
|
|
204
|
+
}
|
|
205
|
+
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
206
|
+
const effCool = Math.min(cool, maxCool ?? cool);
|
|
207
|
+
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
208
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
209
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
213
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
214
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
215
|
+
}
|
|
216
|
+
yield chunk;
|
|
217
|
+
recordLatency(pool, reqStore);
|
|
218
|
+
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
219
|
+
return;
|
|
198
220
|
}
|
|
199
221
|
yield chunk;
|
|
200
|
-
recordLatency(pool, reqStore);
|
|
201
|
-
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
202
|
-
return;
|
|
203
222
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
225
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
226
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
227
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
229
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
230
|
+
pool.state.lastSwitchAt = now();
|
|
231
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
232
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
233
|
+
if (switchNotify && activeRef) {
|
|
234
|
+
notifySwitch(runtime0, pool, {
|
|
235
|
+
provider: options.provider,
|
|
236
|
+
from: activeRef,
|
|
237
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
238
|
+
at: pool.state.lastSwitchAt,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
continue; // Failover to next key!
|
|
224
242
|
}
|
|
225
|
-
|
|
243
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
244
|
+
return;
|
|
226
245
|
}
|
|
227
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
247
|
+
if (switching) continue; // try the next key
|
|
248
|
+
return; // clean end — served
|
|
249
|
+
} finally {
|
|
250
|
+
if (acquired && _pickedRef) {
|
|
251
|
+
concurrencyTracker.release(_pickedRef);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
// pool exhausted — all keys cooling or missing
|
package/lib/routes-ops.js
CHANGED
|
@@ -163,6 +163,8 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
163
163
|
budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
|
|
164
164
|
budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
|
|
165
165
|
pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
|
|
166
|
+
routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
|
|
167
|
+
proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
|
|
166
168
|
});
|
|
167
169
|
} catch (e) {
|
|
168
170
|
console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
package/lib/sandbox.js
CHANGED
|
@@ -83,7 +83,15 @@ export class SandboxRunner {
|
|
|
83
83
|
res = await doFetch();
|
|
84
84
|
} catch (e) {
|
|
85
85
|
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
86
|
-
|
|
86
|
+
// ponytail: 1 retry on transient socket network errors before marking broken
|
|
87
|
+
await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
|
88
|
+
if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
89
|
+
try {
|
|
90
|
+
res = await doFetch();
|
|
91
|
+
} catch (e2) {
|
|
92
|
+
if (e2 && e2.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
93
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
94
|
+
}
|
|
87
95
|
}
|
|
88
96
|
// ponytail: 1 retry on 5xx — naive; classifier refines if needed
|
|
89
97
|
if (res.status >= 500 && res.status < 600) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.10",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|