@goodandready/dsh-key-rotation 0.7.30 → 0.7.32

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.ru.md ADDED
@@ -0,0 +1,254 @@
1
+ # 📦 @goodandready/dsh-key-rotation
2
+
3
+ <div align="center">
4
+
5
+ <h3>Прозрачная ротация API-ключей, предиктивный контроль лимитов и межпровайдерный каскадный failover для DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-key-rotation"><img src="https://img.shields.io/npm/v/@goodandready/dsh-key-rotation.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-key-rotation.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <!-- Обязательная кнопка перехода на витрину всех проектов -->
15
+ <p align="center">
16
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/🌐_DSH_Hub-goodandready.app-ff4500.svg?style=for-the-badge&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="README.md"><b>🇬🇧 English</b></a> •
21
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
22
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
23
+ </p>
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ ## ⚡ Обзор и решаемая проблема
30
+
31
+ ### 🛠️ Что нового в версии 0.7.32 (Хотфикс и повышение стабильности)
32
+ - **🔍 Исправление проверки ключей**: Функция `resolveBaseUrl` теперь сопоставляет ref ключа с его пулом, восстанавливая работу живого тестирования моделей.
33
+ - **🛡️ Защита от зацикливания каскада**: Устранена возможность переполнения стека при взаимных кольцевых цепочках failover.
34
+ - **🕒 Точный сброс квот по PST**: Исправлен знак часового смещения UTC-8 для корректного сброса лимитов в полночь по тихоокеанскому времени.
35
+ - **🧹 Очистка таймеров в жизненном цикле**: Таймеры `canaryTimer` и `selfHealTimer` переведены на эффекты Cordis, исключая утечки при горячей перезагрузке.
36
+ - **⚡ Сброс зависших блокировок**: Алгоритм `pickLeastLoaded` теперь учитывает протухшие блокировки при выборе наименее нагруженного ключа.
37
+ - **🌐 Полная китайская локализация**: Добавлен словарь `zh` в веб-интерфейс настроек для соблюдения стандарта трёх языков (EN/RU/ZH).
38
+
39
+
40
+ ### 🚀 Что нового в версии 0.7.31
41
+ - **⚡ $O(1)$ Накопитель TokenBucket**: Оптимизация математики лимитов до $O(1)$ по времени и без аллокаций памяти с адаптивной синхронизацией по заголовкам.
42
+ - **🛡️ Разделение Soft / Hard сбоев**: Кратковременные сетевые сбои (502/503/таймауты) получают короткий 10-секундный кулдаун без штрафного удвоения.
43
+ - **⏳ Затухание штрафов (Penalty Decay)**: Стабильно работающие ключи автоматически снижают штрафной множитель 1 раз в час.
44
+ - **🎲 Джиттер кулдауна**: Случайный разброс $\pm 12.5\%$ времени разблокировки предотвращает наплыв запросов на апстрим.
45
+ - **🎯 Адресное зондирование модели**: Опция целевого микро-пробинга моделей в Canary Prober.
46
+ - **📊 Перцентили задержки TTFT (p50 / p95 / p99)**: Расчет высокоточных перцентилей задержки первого токена в метриках здоровья.
47
+ - **🔔 Дайджест вебхук-алертов**: Группировка серии быстрых сбоев за 5-секундное окно в единый сводный отчет для Telegram, Discord и Slack.
48
+ - **🧹 30-дневная компактизация**: Автоматическая очистка истории старше 30 дней для защиты от утечек памяти.
49
+ - **✨ Оптимистичный UI и фильтры**: Мгновенный отклик кнопок сброса и статус-пилюли (`Все`, `Готовы`, `В кулдауне`, `С ошибками`) над списком ключей.
50
+
51
+
52
+ При активной работе автономных агентов, параллельном запуске субагентов и циклических вызовах инструментов запросы неизбежно упираются в ограничения провайдеров (ошибки HTTP 429 Too Many Requests, исчерпание суточных квот или временные сбои на стороне апстрима). В стандартной конфигурации DeepSeek Harness исчерпание одного API-ключа полностью блокирует цепочку рассуждений агента, ломает сохранённое состояние диалога (Replay State) и требует ручного вмешательства администратора.
53
+
54
+ **`dsh-key-rotation`** реализует отказоустойчивую корпоративную архитектуру **пулов API-ключей с предиктивным контролем лимитов (Token Bucket) и каскадным переключением на резервных провайдеров**, нативно интегрированную в микроядро Cordis.
55
+
56
+ В отличие от внешних прокси-маршрутизаторов, подменяющих идентификаторы моделей, `dsh-key-rotation` работает на уровне перехвата `ctx.credentials.resolve` и хука `llm/stream`:
57
+ * **Идентичность провайдера остаётся неизменной**: Внутреннее Replay-состояние агента `pi-ai` и контекст инструментов остаются на 100% консистентными.
58
+ * **Предиктивный Token Bucket**: Перегруженные ключи пропускаются **до** выполнения сетевого запроса, устраняя задержку на сетевой ретрай.
59
+ * **Балансировка Least-Connections**: Запросы равномерно распределяются по свободным ключам с контролем параллелизма (`maxConcurrency`).
60
+ * **Автономное самовосстановление и каскад**: Фоновые canary-зонды проверяют заблокированные ключи, а при полном исчерпании пула запрос бесшовно передаётся запасному провайдеру.
61
+
62
+ ---
63
+
64
+ ## 🏗️ Архитектура и жизненный цикл запроса
65
+
66
+ ```mermaid
67
+ graph LR
68
+ subgraph ClientLayer ["Уровень клиента и агента"]
69
+ UserMsg["Сообщение агента / пользователя"] --> Adapter["Адаптер модели pi-ai"]
70
+ end
71
+
72
+ subgraph RotationEngine ["Ядро dsh-key-rotation"]
73
+ Adapter --> StreamHook["Перехватчик llm/stream"]
74
+ StreamHook --> BucketCheck{"Token Bucket\nПроверка RPM / TPM"}
75
+ BucketCheck -->|В пределах нормы| ConcurrencyCheck{"Трекер параллелизма\nLeast-Connections"}
76
+ BucketCheck -->|Лимит исчерпан| NextKey1["Выбор следующего здорового ключа"]
77
+ ConcurrencyCheck -->|Слот свободен| KeyResolver["ctx.credentials.resolve"]
78
+ ConcurrencyCheck -->|Слот занят| NextKey1
79
+
80
+ KeyResolver --> ActiveKey["Активный ключ (в работе)"]
81
+
82
+ ActiveKey -.->|HTTP 429 / Quota / Ошибка| Failover["Мгновенный failover"]
83
+ Failover --> BackoffCalc["Экспоненциальный бэкофф и карантин"]
84
+ Failover --> NextKey2["Повтор со следующим ключом (без потери токенов)"]
85
+ Failover -.->|Все ключи в кулдауне| CascadeEngine["Межпровайдерный каскад"]
86
+
87
+ BackoffCalc --> QuotaWindow["Календарный сброс / Полночь UTC/PST"]
88
+ BackoffCalc --> CanaryProbe["Active Canary-зонд (Sandbox Ping)"]
89
+ CanaryProbe -->|Ключ работоспособен| PoolReady["Возврат в пул готовых ключей"]
90
+ end
91
+
92
+ subgraph UpstreamLayer ["Эндпоинты провайдеров"]
93
+ ActiveKey --> UpstreamAPI["Основной API провайдера"]
94
+ CascadeEngine --> FallbackAPI["Резервный API провайдера"]
95
+ end
96
+
97
+ style ClientLayer fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
98
+ style RotationEngine fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
99
+ style UpstreamLayer fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
100
+ ```
101
+
102
+ ---
103
+
104
+ ## ✨ Исчерпывающий разбор возможностей
105
+
106
+ ### 🔄 1. Прозрачная ротация и мгновенный Failover
107
+ * **Сохранение сессии агента**: Ротация подменяет только физический API-токен, оставляя неизменным ID провайдера. Исключает падения `INVALID_REPLAY_STATE` в мульти-инструментальных сценариях.
108
+ * **Бесшовный повтор до отдачи чанков**: Если ключ падает с ошибкой до начала генерации первого токена, запрос прозрачно перенаправляется на следующий доступный ключ.
109
+ * **Полный спектр кодов переключения**: Авто-переключение при ошибках `QUOTA`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, `UNKNOWN_MODEL`, `AUTH` и `INVALID`.
110
+ * **Интеллектуальный парсер текста ошибок**: Регулярные выражения (`SWITCHABLE_MESSAGE_PATTERN`) распознают текстовые сообщения об исчерпании лимитов, которые SDK провайдеров выбрасывают как неструктурированные исключения.
111
+ * **Защита нестриминговых вызовов**: Синхронные операции (эмбеддинги, батч-оценки) защищены хуком `agent/request-error`.
112
+
113
+ ### ⏱️ 2. Предиктивный Token Bucket и контроль параллелизма
114
+ * **Token Bucket / Leaky Bucket (`lib/bucket.js`)**: Скользящее минутное окно отслеживания запросов (`rpmLimit`) и токенов (`tpmLimit`). Блокирует исчерпанный ключ **до** отправки запроса в сеть, исключая потерю времени на сетевой 429.
115
+ * **Балансировщик Least-Connections (`lib/concurrency.js`)**: Отслеживает активные in-flight соединения на каждом ключе (`inFlight`). Равномерно распределяет параллельную нагрузку и соблюдает лимит `maxConcurrency`.
116
+ * **Авто-очистка зависших блокировок**: При аварийном разрыве сетевых соединений счетчики соединений автоматически очищаются через 5 минут.
117
+
118
+ ### 🛡️ 3. Автономное самовосстановление и каскадный Failover
119
+ * **Межпровайдерный каскад (`lib/cascade.js`)**: При исчерпании всех ключей выбранного провайдера запрос автоматически каскадируется на настроенного резервного провайдера (`cascade: [{ provider, model }]`).
120
+ * **Active Canary Prober (`lib/canary.js`)**: Перед выводом ключа из кулдауна плагин выполняет легкий фоновый зонд (`/models` probe через `SandboxRunner`), защищая боевой трафик от повторных сбоев.
121
+ * **Календарный сброс квот (`lib/quota-window.js`)**: Учитывает окна сброса суточных квот провайдеров (`midnight_utc`, `midnight_pst`, `rolling_24h`), снимая карантин ровно в момент обновления лимитов у апстрима.
122
+ * **Экспоненциальный бэкофф (`lib/pool.js`)**: Повторные сбои на ключе прогрессивно увеличивают время кулдауна (базовое → ×2 → ×4 → максимум ×8).
123
+
124
+ ### 🎯 4. Маршрутизация по моделям и гео-регионам
125
+ * **Модельные подпулы (`lib/pool.js`)**: Назначение выделенных ключей под конкретные модели (например, отдельные ключи для тяжелых reasoning-моделей и дешевые ключи для утилит).
126
+ * **Тегирование ключей**: Метки приоритета (`production`, `background`, `eval`) для разделения квот между интерактивными и фоновыми задачами.
127
+ * **Гео-роутинг (`lib/region.js`)**: Маршрутизация запросов через оптимальные региональные эндпоинты.
128
+
129
+ ### 📊 5. Телеметрия, аналитика и интерактивные вебхуки
130
+ * **Интерактивные вебхуки (`lib/webhook.js`)**: Отправка форматированных алертов с кнопками действий в **Telegram** (Inline Keyboards), **Discord** (Action Rows) и **Slack** (Block Kit). Администратор может сбросить кулдаун или отключить провайдер прямо из мессенджера.
131
+ * **Отчеты об использовании и расходах (`lib/usage-report.js`)**: Учет суточного числа запросов и расчетной стоимости по каждому ключу с экспортом в CSV/JSON (`GET /dsh-key-rotation/usage-report`).
132
+ * **Гистограмма задержек SLO (`lib/histogram.js`)**: Измерение времени до первого токена (TTFT) и расчет индекса здоровья пула (`0..100`).
133
+ * **Автоматические инциденты (`lib/incident.js`)**: Создание issue в GitHub при масштабных системных сбоях провайдеров.
134
+ * **Shadow-трафик (`lib/shadow.js`)**: Теневое дублирование процента запросов для тестирования альтернативных провайдеров.
135
+
136
+ ---
137
+
138
+ ## 🖥️ Панель управления в Web GUI
139
+
140
+ Управление доступно в разделе **Настройки → Ротация ключей** или через быстрый виджет в шапке.
141
+
142
+ | Элемент интерфейса | Описание |
143
+ |---|---|
144
+ | **Виджет в шапке / статусбаре** | Компактный статус: 🟢 `Все в норме` \| 🟡 `Есть ключи в кулдауне` \| 🔴 `Пул исчерпан` с быстрым всплывающим меню. |
145
+ | **Матрица здоровья (1-Click Matrix)** | Интерактивная таблица "Health Matrix" с параллельным зондированием всех ключей, моделей и отображением задержки TTFT. |
146
+ | **Быстрое добавление ключей** | Добавление ключа в 1 клик с автогенерацией имени (`<PROVIDER>_API_KEY`, `_2`, `_3`) и маскировкой. |
147
+ | **Живые бейджи статуса** | Индикаторы: `Используется`, `Готов`, `Остывает` (с живым таймером обратного отсчета) и `Не найден`. |
148
+ | **Приоритет ключей** | Кнопки <kbd>↑</kbd> и <kbd>↓</kbd> для настройки точного порядка перебора в пуле. |
149
+ | **Чекбоксы кодов ошибок** | Наглядные переключатели условий срабатывания ротации. |
150
+ | **Детектор утечек секретов** | Валидация форматов токенов (`lib/keycheck.js`) и защита от случайной вставки приватных SSH/RSA-ключей. |
151
+ | **Импорт из `.env`** | Массовая загрузка пар `KEY=value` из файлов конфигурации. |
152
+ | **Отмена действий (5 сек)** | Всплывающая плашка отмены при случайном удалении ключа или пула. |
153
+ | **График активности** | Наглядная статистика запросов и динамики использования ключей. |
154
+
155
+ ---
156
+
157
+ ## 🔒 Безопасность и хранение секретов
158
+
159
+ * **Никаких открытых секретов в конфигурации**: В настройках плагина хранятся только имена переменных окружения (например, `PROVIDER_API_KEY`).
160
+ * **Защищённое хранилище хоста**: Реальные значения ключей сохраняются в `$DSH_HOME/.credentials.yaml` сервисом `Credentials`.
161
+ * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия.
162
+ * **Ограничение Loopback**: Все управляющие маршруты (`GET /status`, `PUT /key`, `POST /reset`, `POST /test-matrix`) строго проверяют loopback-происхождение запроса (`isTrustedBridgeRequest`).
163
+
164
+ ---
165
+
166
+ ## 📦 Установка
167
+
168
+ ```bash
169
+ # Установка через менеджер плагинов DSH (профиль web):
170
+ dsh plugin --profile web add @goodandready/dsh-key-rotation
171
+
172
+ # Или напрямую из GitHub:
173
+ dsh plugin --profile web add github:GooDAnDReaDY/dsh-key-rotation
174
+ ```
175
+
176
+ > [!IMPORTANT]
177
+ > После установки перезапустите веб-сервис DeepSeek Harness и обновите вкладку браузера:
178
+ > ```bash
179
+ > systemctl --user restart dsh-web
180
+ > ```
181
+
182
+ ---
183
+
184
+ ## ⚙️ Пример конфигурации (`settings.yaml`)
185
+
186
+ ```yaml
187
+ dsh-key-rotation:
188
+ switchCodes:
189
+ - QUOTA
190
+ - RATE_LIMIT
191
+ - SERVER
192
+ - TIMEOUT
193
+ - TRANSPORT
194
+ - EMPTY_RESPONSE
195
+ - UNKNOWN_MODEL
196
+ - AUTH
197
+ cooldownMs: 60000
198
+ canaryProbing: true
199
+ concurrencyLimit: 5
200
+ quotaResetWindow:
201
+ type: midnight_utc
202
+ hour: 0
203
+ cascade:
204
+ - provider: backup-provider-id
205
+ model: your-backup-model-id
206
+ webhookUrl: "https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>"
207
+ providers:
208
+ - provider: your-primary-provider
209
+ rpmLimit: 60
210
+ tpmLimit: 100000
211
+ keys:
212
+ - PRIMARY_API_KEY
213
+ - PRIMARY_API_KEY_2
214
+ - PRIMARY_API_KEY_BACKUP
215
+ - provider: secondary-provider
216
+ keys:
217
+ - SECONDARY_API_KEY
218
+ - SECONDARY_API_KEY_2
219
+ ```
220
+
221
+ ### Таблица параметров
222
+
223
+ | Параметр | Тип | По умолчанию | Описание |
224
+ |---|---|---|---|
225
+ | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | Список кодов ошибок, инициирующих немедленный переход на следующий ключ. |
226
+ | `cooldownMs` | `number` | `60000` (1 мин) | Базовая длительность нахождения ключа в карантине (в мс). |
227
+ | `canaryProbing` | `boolean` | `true` | Фоновая проверка ключа canary-зондом перед возвратом из карантина. |
228
+ | `concurrencyLimit` | `number` | `0` (отключено) | Лимит одновременных активных запросов на ключ (0 = без ограничений). |
229
+ | `quotaResetWindow` | `object` | `null` | Календарное расписание сброса квот (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
230
+ | `cascade` | `array` | `[]` | Цепочка резервных провайдеров при исчерпании всех ключей основного пула. |
231
+ | `webhookUrl` | `string` | `""` | URL вебхука для интерактивных алертов в Telegram, Discord или Slack. |
232
+ | `providers` | `array` | `[]` | Список определений пулов `{ provider, keys, rpmLimit, tpmLimit, modelPools }`. |
233
+
234
+ ---
235
+
236
+ ## 🔌 Справочник HTTP Bridge API
237
+
238
+ Все служебные маршруты требуют локальной авторизации (`127.0.0.1` / `::1`) и проверки Same-Origin:
239
+
240
+ | Маршрут | Метод | Описание |
241
+ |---|---|---|
242
+ | `/dsh-key-rotation/status` | `GET` | Текущий снимок состояния здоровья, активных ключей и кулдаунов. |
243
+ | `/dsh-key-rotation/config` | `GET` / `PUT` | Чтение и изменение активных параметров ротации и пулов провайдеров. |
244
+ | `/dsh-key-rotation/key` | `PUT` / `DELETE` | Добавление, обновление или удаление ключей в хранилище и пуле. |
245
+ | `/dsh-key-rotation/reset` | `POST` | Мгновенный сброс всех кулдаунов и возврат ключей в статус `ready`. |
246
+ | `/dsh-key-rotation/test-matrix` | `POST` | Запуск параллельного тестирования всей матрицы ключей и моделей. |
247
+ | `/dsh-key-rotation/usage-report` | `GET` | Получение сводного отчета использования в формате JSON или CSV (`?format=csv`). |
248
+ | `/dsh-key-rotation/webhook-callback`| `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack. |
249
+
250
+ ---
251
+
252
+ ## 📄 Лицензия
253
+
254
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.zh.md ADDED
@@ -0,0 +1,215 @@
1
+ # 📦 @goodandready/dsh-key-rotation
2
+
3
+ <div align="center">
4
+
5
+ <h3>适用于 DeepSeek Harness 的企业级无感 API 密钥轮换、预判限流与跨提供商故障转移引擎</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-key-rotation"><img src="https://img.shields.io/npm/v/@goodandready/dsh-key-rotation.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-key-rotation.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <!-- 展厅链接 -->
15
+ <p align="center">
16
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/🌐_DSH_Hub-goodandready.app-ff4500.svg?style=for-the-badge&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="README.md"><b>🇬🇧 English</b></a> •
21
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
22
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
23
+ </p>
24
+
25
+ </div>
26
+
27
+ ---
28
+
29
+ ## ⚡ 概述与核心痛点
30
+
31
+ ### 🛠️ v0.7.32 版本新特性 (稳定性与问题修复)
32
+ - **🔍 修复密钥探测 BaseURL 解析**:`resolveBaseUrl` 现已支持从密钥 ref 反查归属提供商池,恢复在线模型连通性探测。
33
+ - **🛡️ 防御级联无限递归**:在跨提供商故障转移中增加递归深度防护,彻底杜绝循环级联导致的堆栈溢出。
34
+ - **🕒 纠正 PST 太平洋时间配额重置**:修复 UTC-8 时区偏移符号,确保日配额在太平洋时间午夜准时重置。
35
+ - **🧹 定时器生命周期自动回收**:将金丝雀探测与自愈定时器纳入 Cordis 效应生命周期,消除热重载遗留孤儿定时器。
36
+ - **⚡ 负载均衡超时锁自动释放**:`pickLeastLoaded` 算法现已检测过期连接锁,确保最小连接调度不发生偏移。
37
+ - **🌐 完整中文界面本地化**:为 React 设置面板补充全部 `zh` 语言包,实现标准的三语(英/俄/中)无缝对齐。
38
+
39
+
40
+ ### 🚀 v0.7.31 版本新特性
41
+ - **⚡ O(1) 令牌桶累加器**:将速率限制计算升级为 O(1) 时间复杂度与零内存分配,并支持响应头自适应同步。
42
+ - **🛡️ 软/硬故障分级退避**:区分临时网络抖动(502/503/超时获得 10 秒平缓冷却)与硬性配额超限(指数退避倍增)。
43
+ - **⏳ 惩罚衰减(Penalty Decay)**:持续稳定运行的密钥每小时自动平减一次失败惩罚系数。
44
+ - **🎲 冷却抖动(Jitter)**:为解锁时间添加 ±12.5% 随机离散度,彻底消除上游惊群效应。
45
+ - **🎯 定向金丝雀探测**:支持针对具体目标模型进行轻量级单 Token 连通性探测。
46
+ - **📊 TTFT 百分位数(p50 / p95 / p99)**:在高精健康度指标中计算首字延迟百分位数。
47
+ - **🔔 Webhook 警报聚合摘要**:在 5 秒窗口内将突发告警合并为单一结构化事件摘要,支持 Telegram/Discord/Slack。
48
+ - **🧹 30 天用量压缩**:自动清理超过 30 天的历史统计数据,保障长期运行内存上限。
49
+ - **✨ 乐观 UI 与快速筛选标签**:一键重置即时生效,密钥列表新增 `全部`、`就绪`、`冷却中`、`故障` 状态筛选胶囊。
50
+
51
+
52
+ 在高吞吐量自主智能体运行、多子智能体并行执行与多轮工具调用场景下,API 极易触发上游服务商的速率限制(HTTP 429 Too Many Requests、RPM/TPM 耗尽、每日配额限制或网络抖动)。在原生的 DeepSeek Harness 中,单个密钥耗尽会导致整个智能体执行链路崩溃,破坏会话的 Replay 状态并要求人工干预。
53
+
54
+ **`dsh-key-rotation`** 基于 Cordis 微内核架构构建,提供了无缝透明的 **API 密钥池轮换、客户端预判限流(Token Bucket)与跨提供商故障转移(Failover Cascade)** 解决方案。
55
+
56
+ 与修改模型提供商 ID 的传统网关代理不同,`dsh-key-rotation` 通过运行时拦截 `ctx.credentials.resolve` 与 `llm/stream` 钩子工作:
57
+ * **保持提供商身份一致**:仅切换底层解析的 API 密钥,维持 `pi-ai` 多轮会话与工具状态 100% 一致。
58
+ * **令牌桶预判限流**:在发起网络请求前预先跳过已饱和的密钥,彻底消除重试网络延迟。
59
+ * **最小连接数并发控制**:动态均衡各密钥的 In-Flight 并发流,防止并发突发拥塞。
60
+ * **金丝雀自愈与级联**:通过轻量 Sandbox 探测探活冷却密钥,密钥全耗尽时自动级联到备用提供商。
61
+
62
+ ---
63
+
64
+ ## 🏗️ 架构与请求生命周期
65
+
66
+ ```mermaid
67
+ graph LR
68
+ subgraph ClientLayer ["客户端与智能体层"]
69
+ UserMsg["用户 / 智能体消息"] --> Adapter["pi-ai 模型适配器"]
70
+ end
71
+
72
+ subgraph RotationEngine ["dsh-key-rotation 核心引擎"]
73
+ Adapter --> StreamHook["llm/stream 拦截器"]
74
+ StreamHook --> BucketCheck{"Token Bucket\nRPM / TPM 校验"}
75
+ BucketCheck -->|未超限| ConcurrencyCheck{"并发跟踪器\n最小连接数"}
76
+ BucketCheck -->|已超限| NextKey1["选取下一可用密钥"]
77
+ ConcurrencyCheck -->|有空闲槽位| KeyResolver["ctx.credentials.resolve"]
78
+ ConcurrencyCheck -->|槽位已满| NextKey1
79
+
80
+ KeyResolver --> ActiveKey["活跃密钥 (执行中)"]
81
+
82
+ ActiveKey -.->|HTTP 429 / Quota / 错误| Failover["即时故障转移"]
83
+ Failover --> BackoffCalc["指数退避与隔离"]
84
+ Failover --> NextKey2["重试下一密钥 (零 Token 丢失)"]
85
+ Failover -.->|所有密钥均在冷却中| CascadeEngine["跨提供商级联"]
86
+
87
+ BackoffCalc --> QuotaWindow["日历重置 / 午夜对齐窗口"]
88
+ BackoffCalc --> CanaryProbe["金丝雀探针 (Sandbox Ping)"]
89
+ CanaryProbe -->|探活成功| PoolReady["恢复至就绪池"]
90
+ end
91
+
92
+ subgraph UpstreamLayer ["上游服务商端点"]
93
+ ActiveKey --> UpstreamAPI["主要提供商 API"]
94
+ CascadeEngine --> FallbackAPI["备用提供商 API"]
95
+ end
96
+
97
+ style ClientLayer fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
98
+ style RotationEngine fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
99
+ style UpstreamLayer fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
100
+ ```
101
+
102
+ ---
103
+
104
+ ## ✨ 核心功能详解
105
+
106
+ ### 🔄 1. 透明轮换与即时故障转移
107
+ * **维持提供商标识一致**:轮换仅替换底层解析的凭证引用,不改变 Provider ID,彻底避免 `INVALID_REPLAY_STATE` 异常。
108
+ * **零 Token 丢失重试**:在首个内容块发出前发生错误时,无感重试并切换至池中下一个健康密钥。
109
+ * **全状态码支持**:支持 `QUOTA`、`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE`、`UNKNOWN_MODEL`、`AUTH` 等。
110
+ * **正则消息模式分类**:内置 `SWITCHABLE_MESSAGE_PATTERN` 正则引擎,自动识别 SDK 抛出的非结构化配额与限流异常。
111
+ * **非流式安全防护**:通过 `agent/request-error` 生命周期钩子保护 Embeddings 及 Batch 调用。
112
+
113
+ ### ⏱️ 2. 预判限流与并发控制
114
+ * **Token Bucket 令牌桶 (`lib/bucket.js`)**:滑动窗口跟踪每分钟请求数 (`rpmLimit`) 与 Token 数 (`tpmLimit`),预先拦截超限密钥。
115
+ * **最小连接负载均衡 (`lib/concurrency.js`)**:实时追踪每把密钥的活跃流数量 (`inFlight`),执行 `maxConcurrency` 限制。
116
+ * **死锁自动释放**:针对网络异常中断连接,超时 5 分钟自动清理占用计数。
117
+
118
+ ### 🛡️ 3. 自动愈合与跨提供商级联
119
+ * **跨提供商故障转移级联 (`lib/cascade.js`)**:主提供商密钥全部冷却时,自动级联路由到备用提供商池。
120
+ * **金丝雀探针探活 (`lib/canary.js`)**:密钥出冷却期前,自动发起轻量探测验证上游可用性,避免影响用户真实请求。
121
+ * **配额日历重置对齐 (`lib/quota-window.js`)**:支持 `midnight_utc`、`midnight_pst` 与 `rolling_24h` 配额刷新窗口。
122
+ * **自适应指数退避 (`lib/pool.js`)**:连续失败使冷却时间呈指数递增(基准 → ×2 → ×4 → 上限 ×8)。
123
+
124
+ ### 📊 4. 统计分析与多平台交互式 Webhook
125
+ * **交互式 Webhook (`lib/webhook.js`)**:向 **Telegram**、**Discord**、**Slack** 推送带交互按钮的富文本警报,可在移动聊天中一键重置冷却或暂停提供商。
126
+ * **使用量与成本报表 (`lib/usage-report.js`)**:按日统计各密钥请求数与预估成本,支持一键导出 CSV/JSON (`GET /dsh-key-rotation/usage-report`)。
127
+ * **延迟 SLO 监控 (`lib/histogram.js`)**:记录首字延迟(TTFT)与健康度评分 (`0..100`)。
128
+ * **影子流量测试 (`lib/shadow.js`)**:支持配置百分比的流量镜像复制以评估次要提供商。
129
+
130
+ ---
131
+
132
+ ## 🖥️ Web GUI 控制台 (**设置 → 密钥轮换**)
133
+
134
+ | 功能 | 说明 |
135
+ |---|---|
136
+ | **顶部状态栏微件** | DSH 顶栏实时健康徽章:🟢 正常 \| 🟡 存在冷却 \| 🔴 密钥池耗尽,点击弹出快速操作面板。 |
137
+ | **一键健康矩阵** | 运行全量密钥与模型并行沙箱测试,直观展示 HTTP 状态码与 TTFT 首字延迟。 |
138
+ | **一键凭证录入** | 点击添加自动生成规范名称(`<PROVIDER>_API_KEY`, `_2`, `_3`),悬停显示尾号。 |
139
+ | **实时状态徽章** | 实时显示:`使用中`、`就绪`、`冷却中`(带倒计时)以及 `凭证未找到`。 |
140
+ | **拖拽与顺序调整** | 使用 <kbd>↑</kbd> 和 <kbd>↓</kbd> 按钮调整轮换优先级。 |
141
+ | **密钥泄漏探测器** | 实时校验输入格式(`sk-...` 等),防止误贴私钥或无关 Token。 |
142
+ | **批量 `.env` 导入** | 支持文件导入解析并自动填充至对应提供商池。 |
143
+ | **5 秒撤销栏** | 误删密钥或提供商时提供 5 秒快速撤销操作。 |
144
+
145
+ ---
146
+
147
+ ## 🔒 安全性与凭证存储
148
+
149
+ * **配置零明文**:插件配置仅保存环境变量引用名(如 `MY_PROVIDER_API_KEY`)。
150
+ * **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
151
+ * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。
152
+ * **环回安全隔离**:管理接口严格限制来自本地同源请求 (`isTrustedBridgeRequest`)。
153
+
154
+ ---
155
+
156
+ ## 📦 安装指南
157
+
158
+ ```bash
159
+ # 通过 DSH 插件管理器安装 (Web Profile):
160
+ dsh plugin --profile web add @goodandready/dsh-key-rotation
161
+
162
+ # 或直接从 GitHub 安装:
163
+ dsh plugin --profile web add github:GooDAnDReaDY/dsh-key-rotation
164
+ ```
165
+
166
+ > [!IMPORTANT]
167
+ > 安装后请重启 DSH Web 服务并刷新浏览器页面:
168
+ > ```bash
169
+ > systemctl --user restart dsh-web
170
+ > ```
171
+
172
+ ---
173
+
174
+ ## ⚙️ 配置示例 (`settings.yaml`)
175
+
176
+ ```yaml
177
+ dsh-key-rotation:
178
+ switchCodes:
179
+ - QUOTA
180
+ - RATE_LIMIT
181
+ - SERVER
182
+ - TIMEOUT
183
+ - TRANSPORT
184
+ - EMPTY_RESPONSE
185
+ - UNKNOWN_MODEL
186
+ - AUTH
187
+ cooldownMs: 60000
188
+ canaryProbing: true
189
+ concurrencyLimit: 5
190
+ quotaResetWindow:
191
+ type: midnight_utc
192
+ hour: 0
193
+ cascade:
194
+ - provider: backup-provider-id
195
+ model: your-backup-model-id
196
+ webhookUrl: "https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>"
197
+ providers:
198
+ - provider: your-primary-provider
199
+ rpmLimit: 60
200
+ tpmLimit: 100000
201
+ keys:
202
+ - PRIMARY_API_KEY
203
+ - PRIMARY_API_KEY_2
204
+ - PRIMARY_API_KEY_BACKUP
205
+ - provider: secondary-provider
206
+ keys:
207
+ - SECONDARY_API_KEY
208
+ - SECONDARY_API_KEY_2
209
+ ```
210
+
211
+ ---
212
+
213
+ ## 📄 开源许可
214
+
215
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/cordis.patch.yml CHANGED
@@ -1,7 +1,7 @@
1
- # dsh-key-rotation bundle layer.
2
- # Registers the virtual provider route "rotation"; the adapter delegates each
3
- # request to the configured clone routes of the matching backend and rotates
4
- # them on switchable failures (QUOTA / RATE_LIMIT / ...).
5
- - insert:
6
- - id: dsh-key-rotation
1
+ # dsh-key-rotation bundle layer.
2
+ # Registers the virtual provider route "rotation"; the adapter delegates each
3
+ # request to the configured clone routes of the matching backend and rotates
4
+ # them on switchable failures (QUOTA / RATE_LIMIT / ...).
5
+ - insert:
6
+ - id: dsh-key-rotation
7
7
  name: '@goodandready/dsh-key-rotation'
@@ -1,68 +1,68 @@
1
- // lib/agent-budget.js — per-agent rate cap.
2
- // ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
3
-
4
- export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
5
- export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
6
- export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
7
-
8
- export class AgentBudget {
9
- constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
10
- const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
11
- const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
12
- this._windowMs = w;
13
- this._limit = l;
14
- this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
15
- }
16
-
17
- isEnabled() {
18
- return this._limit > 0;
19
- }
20
-
21
- // Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
22
- // Records the hit only when allowed.
23
- check(agentId, now = Date.now()) {
24
- if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
25
- if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
26
- let s = this._state.get(agentId);
27
- if (!s) {
28
- s = { hits: [], windowStart: now };
29
- this._state.set(agentId, s);
30
- }
31
- // Window: prune hits older than windowStart + windowMs
32
- const cutoff = now - this._windowMs;
33
- while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
34
- s.windowStart = s.hits.length ? s.hits[0] : now;
35
- if (s.hits.length >= this._limit) {
36
- const resetAt = s.hits[0] + this._windowMs;
37
- return { allowed: false, remaining: 0, resetAt };
38
- }
39
- s.hits.push(now);
40
- return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
41
- }
42
-
43
- // Reset single agent or all
44
- reset(agentId) {
45
- if (agentId) this._state.delete(agentId);
46
- else this._state.clear();
47
- }
48
-
49
- // Inspect-only: return remaining without recording.
50
- peek(agentId, now = Date.now()) {
51
- if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
52
- const s = this._state.get(agentId);
53
- if (!s) return { remaining: this._limit, resetAt: null };
54
- const cutoff = now - this._windowMs;
55
- let count = 0;
56
- for (let i = 0; i < s.hits.length; i++) {
57
- if (s.hits[i] >= cutoff) count += 1;
58
- }
59
- const oldest = s.hits[0];
60
- return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
61
- }
62
-
63
- snapshot() {
64
- const out = {};
65
- for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
66
- return out;
67
- }
68
- }
1
+ // lib/agent-budget.js — per-agent rate cap.
2
+ // ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
3
+
4
+ export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
5
+ export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
6
+ export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
7
+
8
+ export class AgentBudget {
9
+ constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
10
+ const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
11
+ const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
12
+ this._windowMs = w;
13
+ this._limit = l;
14
+ this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
15
+ }
16
+
17
+ isEnabled() {
18
+ return this._limit > 0;
19
+ }
20
+
21
+ // Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
22
+ // Records the hit only when allowed.
23
+ check(agentId, now = Date.now()) {
24
+ if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
25
+ if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
26
+ let s = this._state.get(agentId);
27
+ if (!s) {
28
+ s = { hits: [], windowStart: now };
29
+ this._state.set(agentId, s);
30
+ }
31
+ // Window: prune hits older than windowStart + windowMs
32
+ const cutoff = now - this._windowMs;
33
+ while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
34
+ s.windowStart = s.hits.length ? s.hits[0] : now;
35
+ if (s.hits.length >= this._limit) {
36
+ const resetAt = s.hits[0] + this._windowMs;
37
+ return { allowed: false, remaining: 0, resetAt };
38
+ }
39
+ s.hits.push(now);
40
+ return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
41
+ }
42
+
43
+ // Reset single agent or all
44
+ reset(agentId) {
45
+ if (agentId) this._state.delete(agentId);
46
+ else this._state.clear();
47
+ }
48
+
49
+ // Inspect-only: return remaining without recording.
50
+ peek(agentId, now = Date.now()) {
51
+ if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
52
+ const s = this._state.get(agentId);
53
+ if (!s) return { remaining: this._limit, resetAt: null };
54
+ const cutoff = now - this._windowMs;
55
+ let count = 0;
56
+ for (let i = 0; i < s.hits.length; i++) {
57
+ if (s.hits[i] >= cutoff) count += 1;
58
+ }
59
+ const oldest = s.hits[0];
60
+ return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
61
+ }
62
+
63
+ snapshot() {
64
+ const out = {};
65
+ for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
66
+ return out;
67
+ }
68
+ }