@goodandready/dsh-key-rotation 0.8.47 → 0.8.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.49 - 2026-10-03
4
+
5
+ ### Added
6
+ - **Per-Model Per-Key Token Quotas Documentation & Specification Tests (#442)**:
7
+ - Documented per-model token quota configuration, accounting lifecycle, lazy reset window semantics, and fail-closed isolation rules across `README.md`, `README.ru.md`, and `README.zh.md`.
8
+ - Added Web GUI documentation for model sub-pools & token quota editing, and clarified loopback method-aware Origin validation (`isTrustedBridgeRequest`).
9
+ - Adopted and adapted 5 comprehensive specification test suites from Hipple (`53c479b`):
10
+ - `test/backward-compat.test.mjs`: Legacy configurations parse and operate without quota limits or unintended defaults.
11
+ - `test/integration-acceptance.test.mjs`: End-to-end model quota verification with per-model token exhaustion failover and pool isolation.
12
+ - `test/schema.test.mjs`: Settings schema normalization and optional `@deepseek-ai/schemastery` peer dependency resolution.
13
+ - `test/routes-live-guard.test.mjs`: Method-aware bridge authentication across browser same-origin reads and mutations.
14
+ - `test/persistence-model-quota.test.mjs`: In-memory serialization and durable restore of `tokenUsage` across restarts.
15
+
16
+ ## 0.8.48 - 2026-10-02
17
+
18
+ ### Fixed
19
+ - **Debouncer Throttle Interval Preservation & Delivery Guarantee (#428)**: Ensure `AlertDebouncer.prototype.flush` respects `WebhookSender` inter-batch throttling window (`_minIntervalMs`, default 1000ms) between consecutive batch deliveries across high-burst traffic (e.g. 20 rapid exhaustion events splitting into two 10-incident batches), preventing secondary batches from receiving `throttled: true` drops and guaranteeing all accepted digest alerts are delivered without loss.
20
+
3
21
  ## 0.8.47 - 2026-10-02
4
22
 
5
23
  ### Fixed
package/README.md CHANGED
@@ -162,8 +162,92 @@ graph LR
162
162
 
163
163
  ### 🎯 4. Model-Aware Routing
164
164
  * **Model Sub-Pools (`lib/pool.js`)**: Configure dedicated key pools for specific model tiers (e.g. reasoning/heavy models vs fast/cheap utility models).
165
+ * **Per-Model Per-Key Token Quotas (`lib/model-quota.js`)**: Give one credential its own local token budget for one model. A key that spends its budget is skipped **for that model only** — an exhausted `claude-sonnet` budget never disables the same key for `claude-opus`.
165
166
  * **Tag-Based Routing**: Assign operational tags (`production`, `background`, `eval`) to match key usage with workload priorities.
166
167
 
168
+ #### Per-Model Per-Key Token Quotas
169
+
170
+ `tokenLimit` applies to **one credential in one configured model pool**. The same
171
+ credential can hold an independent budget in every model pool it belongs to:
172
+
173
+ ```yaml
174
+ dsh-key-rotation:
175
+ quotaResetWindow:
176
+ type: midnight_utc
177
+ hour: 0
178
+
179
+ providers:
180
+ - provider: anthropic
181
+
182
+ keys:
183
+ - CLAUDE_KEY_A
184
+ - CLAUDE_KEY_B
185
+
186
+ models:
187
+ claude-sonnet:
188
+ keys:
189
+ - CLAUDE_KEY_A
190
+ - CLAUDE_KEY_B
191
+ quotas:
192
+ CLAUDE_KEY_A:
193
+ tokenLimit: 1000000
194
+ CLAUDE_KEY_B:
195
+ tokenLimit: 1000000
196
+
197
+ claude-opus:
198
+ keys:
199
+ - CLAUDE_KEY_A
200
+ - CLAUDE_KEY_B
201
+ quotas:
202
+ CLAUDE_KEY_A:
203
+ tokenLimit: 200000
204
+ CLAUDE_KEY_B:
205
+ tokenLimit: 200000
206
+ ```
207
+
208
+ Behaviour:
209
+
210
+ ```text
211
+ claude-sonnet request
212
+ → CLAUDE_KEY_A still has Sonnet budget
213
+ → dispatched on CLAUDE_KEY_A
214
+ → actual usage is charged to CLAUDE_KEY_A / Sonnet
215
+ → CLAUDE_KEY_A / Sonnet reaches its limit
216
+ → later Sonnet requests skip CLAUDE_KEY_A and use CLAUDE_KEY_B
217
+ → claude-opus requests may still use CLAUDE_KEY_A
218
+ ```
219
+
220
+ Rules and limits worth knowing:
221
+
222
+ * **`tokenLimit`** is the number of tokens one credential may spend on one model
223
+ pool within the current quota window. A missing, `null`, non-numeric or
224
+ non-positive value means **no local model token limit** — omitting `quotas`
225
+ entirely leaves behaviour byte-identical to previous releases.
226
+ * **`quotaResetWindow`** controls reset timing for these budgets, reusing the
227
+ existing `midnight_utc` / `midnight_pst` / `rolling_24h` settings. Resets are
228
+ lazy: the counter returns to zero when the window elapses, with no per-key timer.
229
+ * **Usage is tracked from the usage returned by successful LLM responses.** Only a
230
+ completed request with usable `usage` is charged. Failed, aborted or refused
231
+ requests are never billed.
232
+ * **Providers that do not report usage cannot be tracked precisely.** Such
233
+ requests are **not guessed at and not deducted**, so a model budget can only be
234
+ exhausted by responses that actually reported token usage.
235
+ * The final request that fits is allowed to overshoot the configured limit
236
+ slightly, and concurrent in-flight requests can also overshoot. This is expected
237
+ behaviour in this version — there is no token reservation.
238
+ * Local model quotas **fail closed**: when no credential has budget left, the
239
+ request is not sent upstream and the original credential is **not** used as a
240
+ fallback. The pool enters the existing exhaustion / cascade flow instead.
241
+ * Exhaustion is a **budget** state, not a credential failure. It never sets a
242
+ cooldown, a failure count or a broken flag, and upstream QUOTA errors stay
243
+ isolated to the model pool that served the request.
244
+ * Editing a limit takes effect immediately: lowering it below the tokens already
245
+ spent marks the credential exhausted at once, raising it restores headroom
246
+ without clearing usage, and deleting `quotas.<REF>` restores `Unlimited`.
247
+ * Only credential **refs** and counters are stored in the state file. The limit
248
+ itself stays in Settings Config, and no API key value is ever written to disk or
249
+ returned by the status API.
250
+
167
251
  ### 📊 5. Observability, Telemetry & Webhooks
168
252
  * **Interactive Multi-Platform Webhooks (`lib/webhook.js`)**: Dispatches rich notifications with HMAC-signed action buttons for **Telegram** (Inline Keyboards), **Discord** (Action Rows), and **Slack** (Block Kit). Administrators can click buttons to reset cooldowns or pause providers directly from their mobile chat.
169
253
  * **Usage & Cost Reporting (`lib/usage-report.js`)**: Per-key daily request counters and estimated cost breakdown with one-click CSV/JSON export (`GET /dsh-key-rotation/usage-report`).
@@ -197,6 +281,7 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
197
281
  | **Secret Leak Detector** | Real-time input sanitizer (`lib/keycheck.js`) catching accidental pastes of private keys, SSH keys, or misplaced tokens. |
198
282
  | **Batch `.env` Import** | Parse standard `.env` key-value pairs directly into corresponding provider pools. |
199
283
  | **5-Second Undo Bar** | Non-destructive undo bar for accidental key or pool removals. |
284
+ | **Model Sub-Pools & Token Quota Editing** | Maintain per-model key lists under each provider, with per-key token limits, live used/limit, percentage, remaining tokens and reset countdown. |
200
285
  | **Usage Analytics Chart** | Interactive breakdown of lifetime requests and daily trends per key. |
201
286
 
202
287
  ---
@@ -206,7 +291,7 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
206
291
  * **Zero Plaintext Secrets in Plugin Config**: Configuration files store only environment variable reference names (e.g. `MY_PROVIDER_API_KEY`).
207
292
  * **Secure Vault Storage**: Actual secret values reside securely in `$DSH_HOME/.credentials.yaml` managed by the DSH `Credentials` service.
208
293
  * **5-Character Masking (`keyTail`)**: Full secret values are never sent to the client browser; only the trailing 5 characters are exposed for visual identification. Short keys (<= 5 characters) return a fixed masked placeholder (`***`) to prevent credential disclosure.
209
- * **Fail-Closed Loopback & Same-Origin Fencing**: Administrative endpoints strictly enforce loopback origin checks (`isTrustedBridgeRequest`), requiring valid `Origin` headers, matching `Host` headers, and rejecting `cross-site` or non-loopback requests without fallbacks.
294
+ * **Fail-Closed Loopback & Same-Origin Fencing**: Administrative endpoints strictly enforce loopback checks (`isTrustedBridgeRequest`): the socket peer and the `Host` header must both be loopback (`127.0.0.1`, `::1`, `localhost`), and `sec-fetch-site: cross-site` is always refused. An attached `Origin` must be an http(s) loopback origin matching `Host` exactly. `Origin` is required on `POST`/`PUT`/`PATCH`/`DELETE` but optional on `GET`/`HEAD`/`OPTIONS`, because browsers omit it on same-origin reads — requiring it there would reject the Settings card's own requests.
210
295
  * **Fail-Closed Resolver on Pool Exhaustion**: When all credentials in a managed pool are exhausted, paused, expired, or blocked by RPM/TPM limits, the resolver fails closed with `LOCAL_POOL_EXHAUSTED` error rather than falling back to leaking unmanaged credentials.
211
296
  * **SSRF Protection & Rebinding Guard**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses including IPv4-mapped and IPv4-compatible IPv6 addresses (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), and enforces connect-time DNS validation via undici agent dispatchers to prevent TOCTOU DNS rebinding.
212
297
  * **Cross-Pool Revocation & Inheritance**: Model pools automatically inherit pause, revoke, and expiry states from their base provider; runtime 401 permanent authentication failures revoke the credential across all shared pools immediately.
@@ -294,10 +379,12 @@ dsh-key-rotation:
294
379
  | `circuitBreakerHalfOpenProbes` | `number` | `1` | Probe requests allowed in half-open state. |
295
380
  | `verboseLogging` | `boolean` | `false` | Per-request rotation logs (noisy; off by default). |
296
381
  | `concurrencyLimit` | `number` | `0` (disabled) | Max concurrent in-flight streams per key (0 = unlimited). |
297
- | `quotaResetWindow` | `object` | `null` | Calendar reset alignment (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
382
+ | `quotaResetWindow` | `object` | `null` | Calendar reset alignment for both provider-reported quota cooldowns and local per-model token budgets (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
298
383
  | `cascade` | `array` | `[]` | Fallback provider chain when primary pool is completely exhausted. |
299
384
  | `webhookUrl` | `string` | `""` | Target URL for interactive Telegram, Discord, Slack, or generic alerts. |
300
- | `providers` | `array` | `[]` | List of `{ provider, keys, rpmLimit, tpmLimit, modelPools }` definitions. |
385
+ | `providers` | `array` | `[]` | List of `{ provider, keys, rpmLimit, tpmLimit, models }` definitions. |
386
+ | `providers[].models` | `object` | `{}` | Per-model sub-pools: `{ <model>: { keys: [...], weights: [...], quotas: { <REF>: { tokenLimit } } } }`. A model pool may also use credentials the provider base pool does not list. |
387
+ | `providers[].models.<model>.quotas` | `object` | `{}` | Local token budgets keyed by credential ref. Omit for unlimited. See [Per-Model Per-Key Token Quotas](#per-model-per-key-token-quotas). |
301
388
 
302
389
  ---
303
390
 
@@ -307,7 +394,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
307
394
 
308
395
  | Route | Method | Description |
309
396
  |---|---|---|
310
- | `/dsh-key-rotation/status` | `GET` | Real-time health, keys, cooldowns. Since v0.8.0 also `providers[].circuit` and `meta` (`expectedClones`, `notifyQueue`). |
397
+ | `/dsh-key-rotation/status` | `GET` | Real-time health, keys, cooldowns. Since v0.8.0 also `providers[].circuit` and `meta` (`expectedClones`, `notifyQueue`). Model sub-pools appear as their own entries with an additive `model` field, and each key carries `modelQuota` (`null` = unlimited). |
311
398
  | `/dsh-key-rotation/config` | `GET` / `PUT` | Read and update active key rotation settings and provider pools. |
312
399
  | `/dsh-key-rotation/key` | `PUT` / `DELETE` | Add, update, or remove credentials in host storage and pool. |
313
400
  | `/dsh-key-rotation/reset` | `POST` | Instantly resets all cooldowns and restores all keys to `ready`. |
package/README.ru.md CHANGED
@@ -160,8 +160,55 @@ graph LR
160
160
 
161
161
  ### 🎯 4. Маршрутизация по моделям
162
162
  * **Модельные подпулы (`lib/pool.js`)**: Назначение выделенных ключей под конкретные модели (например, отдельные ключи для тяжелых reasoning-моделей и дешевые ключи для утилит).
163
+ * **Токен-квоты на пару «модель × ключ» (`lib/model-quota.js`)**: Локальный лимит токенов для отдельного ключа в отдельной модели. Ключ, исчерпавший бюджет, пропускается **только для этой модели**: исчерпанная квота `claude-sonnet` не отключает тот же ключ для `claude-opus`.
163
164
  * **Тегирование ключей**: Метки приоритета (`production`, `background`, `eval`) для разделения квот между интерактивными и фоновыми задачами.
164
165
 
166
+ #### Токен-квоты на пару «модель × ключ»
167
+
168
+ `tokenLimit` действует на **один ключ в одном настроенном модельном пуле**. Каждый пул хранит собственный счетчик, поэтому изоляция «ключ × модель» обеспечивается структурно:
169
+
170
+ ```yaml
171
+ dsh-key-rotation:
172
+ quotaResetWindow:
173
+ type: midnight_utc
174
+ hour: 0
175
+
176
+ providers:
177
+ - provider: anthropic
178
+ keys:
179
+ - CLAUDE_KEY_A
180
+ - CLAUDE_KEY_B
181
+ models:
182
+ claude-sonnet:
183
+ keys:
184
+ - CLAUDE_KEY_A
185
+ - CLAUDE_KEY_B
186
+ quotas:
187
+ CLAUDE_KEY_A:
188
+ tokenLimit: 1000000
189
+ CLAUDE_KEY_B:
190
+ tokenLimit: 1000000
191
+ claude-opus:
192
+ keys:
193
+ - CLAUDE_KEY_A
194
+ - CLAUDE_KEY_B
195
+ quotas:
196
+ CLAUDE_KEY_A:
197
+ tokenLimit: 200000
198
+ CLAUDE_KEY_B:
199
+ tokenLimit: 200000
200
+ ```
201
+
202
+ Что важно знать:
203
+
204
+ * `tokenLimit` — сколько токенов ключ может израсходовать в одном модельном пуле за текущее окно. Отсутствующее, `null`, нечисловое или неположительное значение означает **отсутствие локального лимита**; без секции `quotas` поведение полностью совпадает с предыдущими версиями.
205
+ * **`quotaResetWindow`** управляет моментом сброса (`midnight_utc`, `midnight_pst`, `rolling_24h`). Сброс ленивый: таймеры на каждый ключ не создаются, счетчик обнуляется при первом обращении после `resetAt`.
206
+ * **Расход считается по `usage`, которое вернул успешный ответ.** Ошибки, прерывания, отказы и повторные попытки не тарифицируются; при отсутствии `usage` расход не додумывается.
207
+ * **Провайдеры, не возвращающие `usage`, не могут учитываться точно** — такие запросы не списываются, поэтому бюджет исчерпывается только по реально отчитавшимся ответам.
208
+ * Последний прошедший запрос может немного превысить лимит, как и параллельные запросы: резервирования токенов в этой версии нет.
209
+ * Локальная квота **fail closed**: когда бюджета нет ни у одного ключа, запрос не уходит в апстрим и исходный ключ **не** используется в обход лимита — пул переходит в существующий сценарий исчерпания/каскада.
210
+ * Исчерпание бюджета — это не сбой ключа: кулдаун, счетчики ошибок и флаг `broken` не выставляются, а ошибки QUOTA от провайдера остаются внутри того модельного пула, который обслужил запрос.
211
+
165
212
  ### 📊 5. Телеметрия, аналитика и интерактивные вебхуки
166
213
  * **Интерактивные вебхуки (`lib/webhook.js`)**: Отправка форматированных алертов с кнопками действий в **Telegram** (Inline Keyboards), **Discord** (Action Rows) и **Slack** (Block Kit). Администратор может сбросить кулдаун или отключить провайдер прямо из мессенджера.
167
214
  * **Отчеты об использовании и расходах (`lib/usage-report.js`)**: Учет суточного числа запросов и расчетной стоимости по каждому ключу с экспортом в CSV/JSON (`GET /dsh-key-rotation/usage-report`).
@@ -184,6 +231,7 @@ graph LR
184
231
  | **Детектор утечек секретов** | Валидация форматов токенов (`lib/keycheck.js`) и защита от случайной вставки приватных SSH/RSA-ключей. |
185
232
  | **Импорт из `.env`** | Массовая загрузка пар `KEY=value` из файлов конфигурации. |
186
233
  | **Отмена действий (5 сек)** | Всплывающая плашка отмены при случайном удалении ключа или пула. |
234
+ | **Редактирование модельных пулов и квот токенов** | Управление списками ключей по моделям для каждого провайдера и настройка лимитов токенов с отображением расхода, остатка и таймера сброса. |
187
235
  | **График активности** | Наглядная статистика запросов и динамики использования ключей. |
188
236
 
189
237
  ---
@@ -193,7 +241,7 @@ graph LR
193
241
  * **Никаких открытых секретов в конфигурации**: В настройках плагина хранятся только имена переменных окружения (например, `PROVIDER_API_KEY`).
194
242
  * **Защищённое хранилище хоста**: Реальные значения ключей сохраняются в `$DSH_HOME/.credentials.yaml` сервисом `Credentials`.
195
243
  * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия. Для коротких ключей (длиной <= 5 символов) возвращается безопасная маскировочная заглушка `***`, исключающая раскрытие ключа целиком.
196
- * **Fail-Closed изоляция Loopback и Same-Origin**: Все управляющие эндпоинты строго проверяют происхождение через `isTrustedBridgeRequest`, требуя обязательный заголовок `Origin`, соответствие `Host` и отклоняя запросы `cross-site` и не-loopback без каких-либо исключений.
244
+ * **Fail-Closed изоляция Loopback и Same-Origin**: Все управляющие эндпоинты строго проверяют происхождение через `isTrustedBridgeRequest`: пир сокета и заголовок `Host` обязаны быть loopback (`127.0.0.1`, `::1`, `localhost`), запросы `sec-fetch-site: cross-site` безусловно отклоняются. Заголовок `Origin`, если передан, обязан быть http(s) loopback и в точности совпадать с `Host`. Для `POST`/`PUT`/`PATCH`/`DELETE` заголовок `Origin` обязателен, а для `GET`/`HEAD`/`OPTIONS` допускается его отсутствие, поскольку браузеры не передают `Origin` при чтении с того же источника (иначе страница настроек блокировалась бы сама собой).
197
245
  * **Fail-Closed резолвер при исчерпании пула**: Если все ключи в пуле исчерпаны, приостановлены, истекли или заблокированы по RPM/TPM, резолвер завершается с ошибкой `LOCAL_POOL_EXHAUSTED`, исключая скрытую утечку исходного неконтролируемого ключа.
198
246
  * **SSRF-защита и блокировка DNS Rebinding**: Маршрут импорта пулов провайдеров разрешает исключительно HTTPS, валидирует все IP-адреса, включая IPv4-mapped и IPv4-compatible IPv6 представления (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), и выполняет проверку адреса в момент соединения (connect-time DNS validation) через диспетчер undici для защиты от TOCTOU DNS rebinding.
199
247
  * **Сквозной отзыв и наследование статусов**: Пул конкретной модели автоматически наследует флаги паузы, отзыва и срок действия базового провайдера; перманентный сбой аутентификации 401 немедленно отзывает ключ во всех связанных пулах.
@@ -286,10 +334,12 @@ dsh-key-rotation:
286
334
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | Список кодов ошибок, инициирующих немедленный переход на следующий ключ. |
287
335
  | `cooldownMs` | `number` | `60000` (1 мин) | Базовая длительность нахождения ключа в карантине (в мс). |
288
336
  | `concurrencyLimit` | `number` | `0` (отключено) | Лимит одновременных активных запросов на ключ (0 = без ограничений). |
289
- | `quotaResetWindow` | `object` | `null` | Календарное расписание сброса квот (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
337
+ | `quotaResetWindow` | `object` | `null` | Календарное расписание сброса квот как для апстрима, так и для локальных квот токенов моделей (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
290
338
  | `cascade` | `array` | `[]` | Цепочка резервных провайдеров при исчерпании всех ключей основного пула. |
291
339
  | `webhookUrl` | `string` | `""` | URL вебхука для интерактивных алертов в Telegram, Discord или Slack. |
292
- | `providers` | `array` | `[]` | Список определений пулов `{ provider, keys, rpmLimit, tpmLimit, modelPools }`. |
340
+ | `providers` | `array` | `[]` | Список настроек провайдеров `{ provider, keys, rpmLimit, tpmLimit, models }`. |
341
+ | `providers[].models` | `object` | `{}` | Модельные подпулы: `{ <model>: { keys: [...], weights: [...], quotas: { <REF>: { tokenLimit } } } }`. Модельный пул может содержать ключи, отсутствующие в базовом пуле. |
342
+ | `providers[].models.<model>.quotas` | `object` | `{}` | Локальные бюджеты токенов по ключам. Если не указано — без ограничений. См. [Токен-квоты на пару «модель × ключ»](#токен-квоты-на-пару-модель--ключ). |
293
343
 
294
344
  ---
295
345
 
@@ -299,7 +349,7 @@ dsh-key-rotation:
299
349
 
300
350
  | Маршрут | Метод | Описание |
301
351
  |---|---|---|
302
- | `/dsh-key-rotation/status` | `GET` | Текущий снимок состояния здоровья, активных ключей и кулдаунов. |
352
+ | `/dsh-key-rotation/status` | `GET` | Текущий снимок состояния здоровья, активных ключей и кулдаунов. Модельные подпулы возвращаются как отдельные записи с полем `model`, а каждый ключ содержит `modelQuota` (`null` = без ограничений). |
303
353
  | `/dsh-key-rotation/config` | `GET` / `PUT` | Чтение и изменение активных параметров ротации и пулов провайдеров. |
304
354
  | `/dsh-key-rotation/key` | `PUT` / `DELETE` | Добавление, обновление или удаление ключей в хранилище и пуле. |
305
355
  | `/dsh-key-rotation/reset` | `POST` | Мгновенный сброс всех кулдаунов и возврат ключей в статус `ready`. |
package/README.zh.md CHANGED
@@ -163,6 +163,73 @@ graph LR
163
163
  * **使用量与成本报表 (`lib/usage-report.js`)**:按日统计各密钥请求数与预估成本,支持一键导出 CSV/JSON (`GET /dsh-key-rotation/usage-report`)。
164
164
  * **延迟 SLO 监控 (`lib/histogram.js`)**:记录首字延迟(TTFT)与健康度评分 (`0..100`)。
165
165
 
166
+ ### 🎯 5. 模型级路由与按模型 Token 额度
167
+ * **模型子池(`lib/pool.js`)**:为特定模型层级(如重型推理模型 vs 轻量工具模型)配置专用密钥池。
168
+ * **按「模型 × 密钥」的 Token 额度(`lib/model-quota.js`)**:可为某个凭据在某个模型上单独设置本地 Token 额度。额度耗尽的密钥**只在该模型上被跳过**——`claude-sonnet` 额度用尽绝不会导致同一把密钥的 `claude-opus` 被禁用。
169
+
170
+ #### 按模型、按密钥的 Token 额度
171
+
172
+ `tokenLimit` 作用于**某个模型池中的某一个凭据**。同一凭据可以在它所属的每个模型池中拥有各自独立的额度:
173
+
174
+ ```yaml
175
+ dsh-key-rotation:
176
+ quotaResetWindow:
177
+ type: midnight_utc
178
+ hour: 0
179
+
180
+ providers:
181
+ - provider: anthropic
182
+
183
+ keys:
184
+ - CLAUDE_KEY_A
185
+ - CLAUDE_KEY_B
186
+
187
+ models:
188
+ claude-sonnet:
189
+ keys:
190
+ - CLAUDE_KEY_A
191
+ - CLAUDE_KEY_B
192
+ quotas:
193
+ CLAUDE_KEY_A:
194
+ tokenLimit: 1000000
195
+ CLAUDE_KEY_B:
196
+ tokenLimit: 1000000
197
+
198
+ claude-opus:
199
+ keys:
200
+ - CLAUDE_KEY_A
201
+ - CLAUDE_KEY_B
202
+ quotas:
203
+ CLAUDE_KEY_A:
204
+ tokenLimit: 200000
205
+ CLAUDE_KEY_B:
206
+ tokenLimit: 200000
207
+ ```
208
+
209
+ 行为如下:
210
+
211
+ ```text
212
+ claude-sonnet 请求
213
+ → CLAUDE_KEY_A 的 Sonnet 额度仍有剩余
214
+ → 使用 CLAUDE_KEY_A
215
+ → 按实际 usage 扣除 Token
216
+ → CLAUDE_KEY_A / Sonnet 达到上限
217
+ → 后续 Sonnet 请求跳过 CLAUDE_KEY_A,改用 CLAUDE_KEY_B
218
+ → claude-opus 请求仍可继续使用 CLAUDE_KEY_A
219
+ ```
220
+
221
+ 需要注意的规则与限制:
222
+
223
+ * **`tokenLimit`** 表示单个凭据在单个模型池中、于当前额度周期内可消耗的 Token 数。缺失、`null`、非数字或非正数一律表示**不做本地 Token 限制**——完全不写 `quotas` 时行为与此前版本完全一致。
224
+ * **`quotaResetWindow`** 控制这些额度的重置时机,复用已有的 `midnight_utc` / `midnight_pst` / `rolling_24h` 配置。重置是惰性的:周期结束后首次读取即归零,不会为每把密钥创建定时器。
225
+ * **额度依据成功响应返回的 usage 记账。** 只有正常完成且带有可用 `usage` 的请求才会被扣减;失败、中断、被本地额度拒绝和重试路径都不会计费。
226
+ * **不上报 usage 的提供商无法精确统计。** 这类请求**不会被猜测、也不会被扣减**,因此模型额度只可能被真正上报了 Token 用量的响应耗尽。
227
+ * 允许最后一个放行的请求略微超出配置上限;并发在途请求同样可能产生有限超出。这是当前版本的预期行为——本版本没有 Token 预留机制。
228
+ * 本地模型额度**失败即关闭(fail closed)**:当所有凭据都没有额度时,请求不会发往上游,也**不会**回退到原始凭据绕过限制,而是进入既有的密钥池耗尽 / 级联流程。
229
+ * 额度耗尽属于**预算状态**,不是凭据故障:它不会写入冷却、失败计数或损坏标记;上游返回的 QUOTA 错误也仍然只在真正处理该请求的模型池内生效。
230
+ * 修改额度立即生效:把上限调到已用量之下会立刻变为已耗尽;调高则自动重新计算剩余量且不清空已用量;删除 `quotas.<REF>` 立即恢复为不限额。
231
+ * 状态文件中只保存凭据 **ref** 与计数器,额度上限本身仍来自设置配置;磁盘中不会写入任何真实 API Key,状态接口也不会返回密钥值。
232
+
166
233
  ---
167
234
 
168
235
  ## 🖥️ Web GUI 控制台 (**设置 → 密钥轮换**)
@@ -177,6 +244,7 @@ graph LR
177
244
  | **密钥泄漏探测器** | 实时校验输入格式(`sk-...` 等),防止误贴私钥或无关 Token。 |
178
245
  | **批量 `.env` 导入** | 支持文件导入解析并自动填充至对应提供商池。 |
179
246
  | **5 秒撤销栏** | 误删密钥或提供商时提供 5 秒快速撤销操作。 |
247
+ | **模型子池与 Token 额度编辑** | 在每个提供商下按模型维护凭据列表,并逐个设置 Token 额度;实时显示 `已用 / 上限`、百分比、剩余量与重置倒计时,未配置时显示 `不限额`。 |
180
248
 
181
249
  ---
182
250
 
@@ -185,7 +253,7 @@ graph LR
185
253
  * **配置零明文**:插件配置仅保存环境变量引用名(如 `MY_PROVIDER_API_KEY`)。
186
254
  * **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
187
255
  * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。对于长度 <= 5 的短密钥,返回统一脱敏占位符 (`***`),防止凭证完整泄露。
188
- * **Fail-Closed 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 验证本地同源请求,强制要求有效 `Origin` 和 `Host` 匹配,无条件拒绝跨站 (`cross-site`) 与非环回调用。
256
+ * **Fail-Closed 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 校验:套接字对端与 `Host` 头必须均为环回地址(`127.0.0.1`、`::1`、`localhost`),并始终拒绝 `sec-fetch-site: cross-site`。若请求携带 `Origin`,则必须是 http(s) 环回源且与 `Host` 完全一致。`POST`/`PUT`/`PATCH`/`DELETE` 必须携带 `Origin`,而 `GET`/`HEAD`/`OPTIONS` 允许省略——因为浏览器在同源读取请求中不会发送该头,强制要求会连带拒绝设置面板自身的请求。
189
257
  * **凭证池耗尽 Fail-Closed 熔断**:当受管凭证池中所有密钥均被耗尽、暂停、过期或触发 RPM/TPM 限制时,解析器立即返回 `LOCAL_POOL_EXHAUSTED` 错误熔断,杜绝静默回退泄漏未受控原始密钥。
190
258
  * **SSRF 与 DNS 重绑定防护**:远程配置导入严格仅支持 HTTPS,全面校验解析 IP 并拦截 IPv4-mapped/IPv4-compatible 等各类 IPv6 环回变体(如 `::ffff:127.0.0.1`、`::ffff:7f00:1`、`64:ff9b::/96`),并通过 undici 调度器执行连接时(connect-time)DNS 校验,从根本上防御 TOCTOU DNS 重绑定。
191
259
  * **跨池吊销与状态继承**:模型子池自动继承基础提供商的暂停、吊销与过期时间;运行时 401 永久认证失败将即时在所有关联共享池中统一标记吊销。
package/lib/webhook.js CHANGED
@@ -265,7 +265,26 @@ export class AlertDebouncer {
265
265
  }
266
266
 
267
267
  const sendBatch = async () => {
268
- const res = await this._sender.send(url, payload);
268
+ if (this._sender && typeof this._sender._lastSentAt?.get === 'function') {
269
+ const last = this._sender._lastSentAt.get(url);
270
+ const minInterval = Number.isFinite(this._sender._minIntervalMs) ? this._sender._minIntervalMs : 1000;
271
+ if (Number.isFinite(last)) {
272
+ const elapsed = Date.now() - last;
273
+ if (elapsed < minInterval) {
274
+ await new Promise((r) => setTimeout(r, minInterval - elapsed + 20));
275
+ }
276
+ }
277
+ }
278
+ let res = await this._sender.send(url, payload);
279
+ while (res && res.throttled) {
280
+ const last = (this._sender && typeof this._sender._lastSentAt?.get === 'function')
281
+ ? this._sender._lastSentAt.get(url) : null;
282
+ const minInterval = (this._sender && this._sender._minIntervalMs) || 1000;
283
+ const now = Date.now();
284
+ const wait = Number.isFinite(last) ? Math.max(50, minInterval - (now - last) + 20) : minInterval;
285
+ await new Promise((r) => setTimeout(r, wait));
286
+ res = await this._sender.send(url, payload);
287
+ }
269
288
  for (const cb of entry.callbacks) {
270
289
  bestEffort('webhook.callback', () => { cb(res); }, this._logger);
271
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.47",
3
+ "version": "0.8.49",
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": [