@goodandready/dsh-key-rotation 0.7.37 → 0.7.39

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 CHANGED
@@ -253,6 +253,18 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
253
253
 
254
254
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
255
 
256
+ ### v0.7.39
257
+ - **Self-Healing & lastUsedAt Accuracy**: Fixed key timestamp lookup in `healIdleCooldowns` to read from the modern `pool.state.lastUsedAt` map (with backwards-compatible fallback). `credentials.resolve` now properly records each key invocation timestamp in `lastUsedAt`, surfacing accurate "last used" indicators in the status dashboard and enabling background idle cooldown restoration.
258
+ - **Hotpath & Runtime Memoization**: Eliminated redundant `buildRuntime()` calls across periodic sweeps, provider exhaustion handling, and `/status` query processing.
259
+ - **Test Suite & CI Hardening**: Periodic interval sweep timers and debounce timers unref'ed to allow Node.js event loop natural exit without stalling CI runners. Purged obsolete incident test artifacts.
260
+
261
+ ### v0.7.38
262
+ - **Hot-Path Stream Optimization**: Eliminated 4 redundant `buildRuntime()` calls inside the `rotate()` finish chunk handler by reusing the request-scoped `runtime0` snapshot.
263
+ - **Allocation-Free Rate Limit Header Parsing**: Optimized `extractRateLimit()` with single-pass header inspection and length guards, completely removing dynamic lowercase/uppercase string allocations on every response chunk.
264
+ - **Date ISO String Memoization**: Memoized `todayIso` string calculation once per finishing request instead of creating multiple `Date` instances for `costDays` and `usageDays`.
265
+ - **Zero-Allocation Metrics Aggregation**: Replaced intermediate array allocation `[...values()].reduce()` with iterative summation for `totalUsage` in the `/dsh-key-rotation/status` endpoint.
266
+ - **Stale Notification Cleanup**: Automatically purge stale entries in notification throttling maps (`budgetNotifiedAt`, `lowHealthNotifiedAt`) when provider pools are deleted.
267
+
256
268
  ### v0.7.37
257
269
  - **Request Context Isolation via AsyncLocalStorage**: Scoped active key resolution (`pickedRef`), start timestamps, and retry counts strictly to each async dispatch context using Node's `node:async_hooks`. Eliminates race conditions where concurrent streaming requests could penalize healthy keys.
258
270
  - **Failover on Pre-Yield Stream Exceptions**: Fixed fatal stream termination where transport errors (e.g. HTTP 429 thrown before headers, socket hang-up) aborted the generator. The catch block now inspects `isSwitchableError` and cascades seamlessly to the next key if no content tokens have been yielded.
package/README.ru.md CHANGED
@@ -253,6 +253,18 @@ dsh-key-rotation:
253
253
 
254
254
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
255
 
256
+ ### v0.7.39
257
+ - **Самоисцеление ключей и фиксация lastUsedAt**: Исправлено чтение меток времени в `healIdleCooldowns`, использующее теперь карту `pool.state.lastUsedAt`. В `credentials.resolve` добавлено сохранение точного времени каждого обращения к ключу, что активирует вывод времени использования в панели мониторинга и корректное самоисцеление ключей после простоя.
258
+ - **Оптимизация обращений к buildRuntime**: Устранены повторные вызовы `buildRuntime()` в процедурах периодической очистки, маршруте `/status` и блоке обработки исчерпания пула.
259
+ - **Стабилизация CI и устранение утечек таймеров**: Фоновые таймеры дебаунса и очистки теперь отвязываются (`unref`) от цикла событий Node.js, предотвращая зависание раннера. Удалены тесты устаревшего модуля инцидентов.
260
+
261
+ ### v0.7.38
262
+ - **Оптимизация горячего пути стриминга**: Устранены 4 избыточных вызова `buildRuntime()` при обработке завершающего чанка в `rotate()` за счёт повторного использования снимка `runtime0`.
263
+ - **Парсинг заголовков лимитов без лишних аллокаций**: Оптимизирована функция `extractRateLimit()` — однократный проход по объекту с проверкой длины ключей без постоянных аллокаций строк `.toLowerCase()` и `.toUpperCase()`.
264
+ - **Мемоизация строки даты ISO**: Дата текущего дня (`todayIso`) теперь вычисляется ровно один раз на запрос, исключая создание нескольких дублирующих объектов `Date` для `costDays` и `usageDays`.
265
+ - **Итеративный подсчет totalUsage**: В эндпоинте статуса `/dsh-key-rotation/status` расчёт суммарного потребления переведён на прямой цикл без аллокации промежуточного массива `[...values()]`.
266
+ - **Очистка устаревших записей уведомлений**: При удалении пулов провайдеров из конфигурации автоматически вычищаются связанные ключи из карт троттлинга уведомлений (`budgetNotifiedAt`, `lowHealthNotifiedAt`).
267
+
256
268
  ### v0.7.37
257
269
  - **Изоляция контекста запросов через AsyncLocalStorage**: Полная привязка активного ключа (`pickedRef`), времени старта и попыток к асинхронному контексту вызова через `node:async_hooks`. Устранена гонка, при которой параллельные запросы могли ошибочно штрафовать здоровый ключ соседа.
258
270
  - **Failover при исключениях в потоке до первого чанка**: Устранено аварийное прерывание потока при транспортных сбоях (например, выброс HTTP 429 до отправки заголовков). Теперь блок перехвата проверяет `isSwitchableError` и прозрачно переключает поток на запасной ключ, если клиенту ещё не было отдано полезных данных.
package/README.zh.md CHANGED
@@ -214,6 +214,18 @@ dsh-key-rotation:
214
214
 
215
215
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
216
216
 
217
+ ### v0.7.39
218
+ - **自动恢复与 lastUsedAt 修复**:修复了 `healIdleCooldowns` 中对密钥调用时间戳的读取逻辑,直接从 `lastUsedAt` 映射读取。在 `credentials.resolve` 中补充记录每次密钥调用的时间戳,使状态面板的最近使用时间生效并正确支持空闲密钥恢复。
219
+ - **运行期快照缓存优化**:消除了定时维护清理、`/status` 路由及密钥池耗尽处理中重复调用 `buildRuntime()` 的开销。
220
+ - **CI 测试稳定性提升**:对维护定时器与通知防抖定时器执行 `unref`,确保 Node.js 事件循环在测试完成后干净退出,彻底解决 CI 运行器假死问题。
221
+
222
+ ### v0.7.38
223
+ - **热路径流处理优化**:在 `rotate()` 结束块处理中消除 4 次多余的 `buildRuntime()` 重复调用,直接复用请求作用域内的 `runtime0` 快照。
224
+ - **零额外字符串分配的限流头解析**:重构 `extractRateLimit()`,采用单次遍历结合键长度检查,彻底消除对每个响应头执行 `.toLowerCase()` / `.toUpperCase()` 的内存碎片分配。
225
+ - **日期 ISO 字符串记忆化**:在统计 `costDays` 与 `usageDays` 时将 `todayIso` 计算收敛为单次,杜绝重复创建 `Date` 实例。
226
+ - **状态统计零数组分配**:在 `/dsh-key-rotation/status` 中将 `totalUsage` 的计算由 `[...values()].reduce()` 改为直接迭代累加,避免频繁轮询引发的垃圾回收波动。
227
+ - **孤立通知记录自动清理**:在配置移除提供商模型池时,自动清理关联的通知限频缓存。
228
+
217
229
  ### v0.7.37
218
230
  - **通过 AsyncLocalStorage 隔离请求上下文**:使用 Node.js 的 `node:async_hooks` 将解析后的密钥 (`pickedRef`)、启动时间和重试严格限定在单个请求上下文内,彻底消除并发请求间的竞态条件与误罚。
219
231
  - **流异常自动故障转移**:修复流在首个 token 返回前抛出传输异常(如 HTTP 429)直接终止的问题。若未发送内容块,现在会自动触发 `isSwitchableError` 并顺畅切换到备用密钥。
package/lib/heal.js CHANGED
@@ -11,13 +11,13 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
11
11
  for (const pool of pools) {
12
12
  if (!pool || !pool.state || !pool.base) continue;
13
13
  const fu = pool.state.failedUntil;
14
- const lu = pool.state.lastUsed;
14
+ const lua = pool.state.lastUsedAt ?? pool.state.lastUsed;
15
15
  if (!fu || fu.size === 0) continue;
16
16
  const expiredRefs = [];
17
17
  for (const [ref, until] of fu.entries()) {
18
18
  if (!Number.isFinite(until)) continue;
19
19
  if (until > now) continue; // cooldown still active
20
- const last = lu ? lu.get(ref) : undefined;
20
+ const last = typeof lua?.get === 'function' ? lua.get(ref) : undefined;
21
21
  if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
22
  if (now - last < idleMs) continue; // used recently → don't heal
23
23
  expiredRefs.push(ref);
package/lib/index.js CHANGED
@@ -38,7 +38,7 @@ import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_
38
38
 
39
39
  export const name = 'dsh-key-rotation';
40
40
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
41
- export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage };
41
+ export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
42
42
 
43
43
  /** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
44
44
  const NS = 'dsh-key-rotation';
@@ -452,13 +452,13 @@ export function apply(ctx, config = {}) {
452
452
  }
453
453
  const n = sweepExpired(poolState, now);
454
454
  if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
455
- for (const pool of buildRuntime().poolByRef.values()) {
455
+ const runtime = buildRuntime();
456
+ for (const pool of runtime.poolByRef.values()) {
456
457
  compactUsage(pool, 30, now);
457
458
  }
458
459
  // #207 expiry pre-warning + #208 cost budget - piggybacked on this timer,
459
460
  // deduped to one notification per key/window per day (shouldNotifyDaily).
460
461
  try {
461
- const runtime = buildRuntime();
462
462
  const seen = new Set();
463
463
  for (const pool of runtime.poolByRef.values()) {
464
464
  if (seen.has(pool.base)) continue;
@@ -563,6 +563,7 @@ export function apply(ctx, config = {}) {
563
563
  }
564
564
  } catch (_) { /* maintenance must never crash the sweep */ }
565
565
  }, 30000);
566
+ if (typeof id.unref === 'function') id.unref();
566
567
  return () => clearInterval(id);
567
568
  }, 'dsh-key-rotation: sweep expired cooldowns');
568
569
 
@@ -695,7 +696,11 @@ export function apply(ctx, config = {}) {
695
696
 
696
697
  // auto-cleanup: remove poolState for providers that are now empty or removed
697
698
  for (const key of [...poolState.keys()]) {
698
- if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
699
+ if (![...poolByRef.values()].some((p) => p.base === key)) {
700
+ poolState.delete(key);
701
+ lowHealthNotifiedAt.delete(key);
702
+ budgetNotifiedAt.delete(key + ':budget');
703
+ }
699
704
  }
700
705
  // #192: drop RPM windows for refs that no longer belong to any pool
701
706
  for (const st of poolState.values()) {
@@ -767,6 +772,8 @@ export function apply(ctx, config = {}) {
767
772
  let hit = await original(candidate);
768
773
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
769
774
  pool.state.lastUsed = candidate;
775
+ if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
776
+ pool.state.lastUsedAt.set(candidate, now);
770
777
  const store = dispatchStorage.getStore();
771
778
  if (store && store.pool === pool) store.pickedRef = candidate;
772
779
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
@@ -788,6 +795,8 @@ export function apply(ctx, config = {}) {
788
795
  const envVal = envValue(candidate);
789
796
  if (envVal !== undefined) {
790
797
  pool.state.lastUsed = candidate;
798
+ if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
799
+ pool.state.lastUsedAt.set(candidate, now);
791
800
  const store = dispatchStorage.getStore();
792
801
  if (store && store.pool === pool) store.pickedRef = candidate;
793
802
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
@@ -845,12 +854,11 @@ export function apply(ctx, config = {}) {
845
854
  // the resolve patch hands out the next key on each dispatch.
846
855
  function rotate(options, pool) {
847
856
  return (async function* () {
848
- const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
857
+ const runtime0 = buildRuntime();
858
+ const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
849
859
  let lastFailure = null;
850
860
  const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
851
861
  _rotateStartMs = reqStore.startMs;
852
-
853
- const runtime0 = buildRuntime();
854
862
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
855
863
  if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
856
864
  // #193: prefer least-loaded key within limit
@@ -936,8 +944,8 @@ export function apply(ctx, config = {}) {
936
944
  lastFailure = chunk;
937
945
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
938
946
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
939
- if (buildRuntime().switchNotify && activeRef) {
940
- notifySwitch(buildRuntime(), pool, {
947
+ if (switchNotify && activeRef) {
948
+ notifySwitch(runtime0, pool, {
941
949
  provider: options.provider,
942
950
  from: activeRef,
943
951
  code: String(code ?? 'UNKNOWN'),
@@ -948,6 +956,7 @@ export function apply(ctx, config = {}) {
948
956
  break;
949
957
  }
950
958
  // cost tracking if provider returns usage.cost
959
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
951
960
  if (chunk.usage?.cost != null && activeRef) {
952
961
  const c = Number(chunk.usage.cost);
953
962
  if (!isNaN(c)) {
@@ -955,18 +964,16 @@ export function apply(ctx, config = {}) {
955
964
  pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
956
965
  // #208: cost per day per key (mirrors usageDays) for budget checks
957
966
  if (!pool.state.costDays) pool.state.costDays = new Map();
958
- const cday = new Date().toISOString().slice(0, 10);
959
967
  const cMap = pool.state.costDays.get(activeRef) || new Map();
960
- cMap.set(cday, (cMap.get(cday) ?? 0) + c);
968
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
961
969
  pool.state.costDays.set(activeRef, cMap);
962
970
  }
963
971
  }
964
972
  // Usage by day (#119)
965
973
  if (activeRef) {
966
974
  if (!pool.state.usageDays) pool.state.usageDays = new Map();
967
- const day = new Date().toISOString().slice(0, 10);
968
975
  const dayMap = pool.state.usageDays.get(activeRef) || new Map();
969
- dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
976
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
970
977
  pool.state.usageDays.set(activeRef, dayMap);
971
978
  }
972
979
  // Per-model request detail (#121)
@@ -981,7 +988,6 @@ export function apply(ctx, config = {}) {
981
988
  // We do NOT re-run this (already successful) request — that would double-send.
982
989
  const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
983
990
  if (rate && activeRef) {
984
- const { rateLimitThreshold } = buildRuntime();
985
991
  if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
986
992
  const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
987
993
  recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
@@ -1010,8 +1016,8 @@ export function apply(ctx, config = {}) {
1010
1016
  pool.state.lastSwitchAt = Date.now();
1011
1017
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
1012
1018
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
1013
- if (buildRuntime().switchNotify && activeRef) {
1014
- notifySwitch(buildRuntime(), pool, {
1019
+ if (switchNotify && activeRef) {
1020
+ notifySwitch(runtime0, pool, {
1015
1021
  provider: options.provider,
1016
1022
  from: activeRef,
1017
1023
  code: String(e?.code ?? 'TRANSPORT'),
@@ -1033,11 +1039,11 @@ export function apply(ctx, config = {}) {
1033
1039
  pool.state.lastExhaustionAt = Date.now();
1034
1040
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
1035
1041
  console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
1042
+ const runtime = buildRuntime();
1036
1043
  // notify via extracted helper (see notifyExhaustion above)
1037
- notifyExhaustion(buildRuntime(), pool, { provider: options.provider });
1044
+ notifyExhaustion(runtime, pool, { provider: options.provider });
1038
1045
 
1039
1046
  // #194: cross-provider cascade failover (guarded against infinite recursion)
1040
- const runtime = buildRuntime();
1041
1047
  if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
1042
1048
  const pools = runtime.providerToPool;
1043
1049
  const fb = pickCascadeFallback(options.provider, runtime, pools);
@@ -1078,10 +1084,10 @@ export function apply(ctx, config = {}) {
1078
1084
  json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
1079
1085
  return;
1080
1086
  }
1081
- const { poolByRef, providerTags, providerBudgets } = buildRuntime();
1087
+ const runtime = buildRuntime();
1088
+ const { poolByRef, providerTags, providerBudgets, latencySloMs } = runtime;
1082
1089
  const base = ctx.get('credentials');
1083
1090
  const now = Date.now();
1084
- const latencySloMs = buildRuntime().latencySloMs;
1085
1091
  const seen = new Set();
1086
1092
  const providers = [];
1087
1093
  for (const pool of poolByRef.values()) {
@@ -1148,7 +1154,7 @@ export function apply(ctx, config = {}) {
1148
1154
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
1149
1155
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1150
1156
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1151
- totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
1157
+ totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
1152
1158
  // #225: aggregate p95 across the pool's keys
1153
1159
  p95: (() => {
1154
1160
  const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
package/lib/pool.js CHANGED
@@ -248,14 +248,23 @@ export function computeHealthScore(state) {
248
248
  /** Extract rate-limit info from an object that may carry response headers. */
249
249
  export function extractRateLimit(headers) {
250
250
  if (!headers || typeof headers !== 'object') return null;
251
- const get = (name) => {
252
- const v = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
253
- if (v === undefined || v === null) return undefined;
254
- return Number(String(v));
255
- };
256
- const remaining = get('X-RateLimit-Remaining');
257
- const limit = get('X-RateLimit-Limit');
258
- const reset = get('X-RateLimit-Reset');
251
+ let remaining, limit, reset;
252
+ for (const k in headers) {
253
+ const lk = k.length;
254
+ if (lk === 21 || lk === 16 || lk === 17) {
255
+ const lower = k.toLowerCase();
256
+ if (lower === 'x-ratelimit-remaining') {
257
+ const v = headers[k];
258
+ if (v != null) remaining = Number(String(v));
259
+ } else if (lower === 'x-ratelimit-limit') {
260
+ const v = headers[k];
261
+ if (v != null) limit = Number(String(v));
262
+ } else if (lower === 'x-ratelimit-reset') {
263
+ const v = headers[k];
264
+ if (v != null) reset = Number(String(v));
265
+ }
266
+ }
267
+ }
259
268
  if (remaining === undefined && limit === undefined) return null;
260
269
  return { remaining, limit, reset };
261
270
  }
package/lib/webhook.js CHANGED
@@ -157,6 +157,7 @@ export class AlertDebouncer {
157
157
  }
158
158
  if (!entry.timer) {
159
159
  entry.timer = setTimeout(() => this.flush(url), this._debounceMs);
160
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
160
161
  }
161
162
  }
162
163
 
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.37",
4
- "packageManager": "pnpm@10.33.2",
3
+ "version": "0.7.39",
5
4
  "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
5
  "keywords": [
7
6
  "deepseek-harness",
@@ -54,6 +53,6 @@
54
53
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
55
54
  },
56
55
  "scripts": {
57
- "test": "node --test test/*.test.js test/*.test.mjs"
56
+ "test": "node --test --test-timeout=10000 test/*.test.js test/*.test.mjs"
58
57
  }
59
- }
58
+ }