@goodandready/dsh-key-rotation 0.7.37 → 0.7.38

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,13 @@ 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.38
257
+ - **Hot-Path Stream Optimization**: Eliminated 4 redundant `buildRuntime()` calls inside the `rotate()` finish chunk handler by reusing the request-scoped `runtime0` snapshot.
258
+ - **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.
259
+ - **Date ISO String Memoization**: Memoized `todayIso` string calculation once per finishing request instead of creating multiple `Date` instances for `costDays` and `usageDays`.
260
+ - **Zero-Allocation Metrics Aggregation**: Replaced intermediate array allocation `[...values()].reduce()` with iterative summation for `totalUsage` in the `/dsh-key-rotation/status` endpoint.
261
+ - **Stale Notification Cleanup**: Automatically purge stale entries in notification throttling maps (`budgetNotifiedAt`, `lowHealthNotifiedAt`) when provider pools are deleted.
262
+
256
263
  ### v0.7.37
257
264
  - **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
265
  - **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,13 @@ dsh-key-rotation:
253
253
 
254
254
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
255
 
256
+ ### v0.7.38
257
+ - **Оптимизация горячего пути стриминга**: Устранены 4 избыточных вызова `buildRuntime()` при обработке завершающего чанка в `rotate()` за счёт повторного использования снимка `runtime0`.
258
+ - **Парсинг заголовков лимитов без лишних аллокаций**: Оптимизирована функция `extractRateLimit()` — однократный проход по объекту с проверкой длины ключей без постоянных аллокаций строк `.toLowerCase()` и `.toUpperCase()`.
259
+ - **Мемоизация строки даты ISO**: Дата текущего дня (`todayIso`) теперь вычисляется ровно один раз на запрос, исключая создание нескольких дублирующих объектов `Date` для `costDays` и `usageDays`.
260
+ - **Итеративный подсчет totalUsage**: В эндпоинте статуса `/dsh-key-rotation/status` расчёт суммарного потребления переведён на прямой цикл без аллокации промежуточного массива `[...values()]`.
261
+ - **Очистка устаревших записей уведомлений**: При удалении пулов провайдеров из конфигурации автоматически вычищаются связанные ключи из карт троттлинга уведомлений (`budgetNotifiedAt`, `lowHealthNotifiedAt`).
262
+
256
263
  ### v0.7.37
257
264
  - **Изоляция контекста запросов через AsyncLocalStorage**: Полная привязка активного ключа (`pickedRef`), времени старта и попыток к асинхронному контексту вызова через `node:async_hooks`. Устранена гонка, при которой параллельные запросы могли ошибочно штрафовать здоровый ключ соседа.
258
265
  - **Failover при исключениях в потоке до первого чанка**: Устранено аварийное прерывание потока при транспортных сбоях (например, выброс HTTP 429 до отправки заголовков). Теперь блок перехвата проверяет `isSwitchableError` и прозрачно переключает поток на запасной ключ, если клиенту ещё не было отдано полезных данных.
package/README.zh.md CHANGED
@@ -214,6 +214,13 @@ dsh-key-rotation:
214
214
 
215
215
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
216
216
 
217
+ ### v0.7.38
218
+ - **热路径流处理优化**:在 `rotate()` 结束块处理中消除 4 次多余的 `buildRuntime()` 重复调用,直接复用请求作用域内的 `runtime0` 快照。
219
+ - **零额外字符串分配的限流头解析**:重构 `extractRateLimit()`,采用单次遍历结合键长度检查,彻底消除对每个响应头执行 `.toLowerCase()` / `.toUpperCase()` 的内存碎片分配。
220
+ - **日期 ISO 字符串记忆化**:在统计 `costDays` 与 `usageDays` 时将 `todayIso` 计算收敛为单次,杜绝重复创建 `Date` 实例。
221
+ - **状态统计零数组分配**:在 `/dsh-key-rotation/status` 中将 `totalUsage` 的计算由 `[...values()].reduce()` 改为直接迭代累加,避免频繁轮询引发的垃圾回收波动。
222
+ - **孤立通知记录自动清理**:在配置移除提供商模型池时,自动清理关联的通知限频缓存。
223
+
217
224
  ### v0.7.37
218
225
  - **通过 AsyncLocalStorage 隔离请求上下文**:使用 Node.js 的 `node:async_hooks` 将解析后的密钥 (`pickedRef`)、启动时间和重试严格限定在单个请求上下文内,彻底消除并发请求间的竞态条件与误罚。
219
226
  - **流异常自动故障转移**:修复流在首个 token 返回前抛出传输异常(如 HTTP 429)直接终止的问题。若未发送内容块,现在会自动触发 `isSwitchableError` 并顺畅切换到备用密钥。
package/lib/index.js CHANGED
@@ -695,7 +695,11 @@ export function apply(ctx, config = {}) {
695
695
 
696
696
  // auto-cleanup: remove poolState for providers that are now empty or removed
697
697
  for (const key of [...poolState.keys()]) {
698
- if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
698
+ if (![...poolByRef.values()].some((p) => p.base === key)) {
699
+ poolState.delete(key);
700
+ lowHealthNotifiedAt.delete(key);
701
+ budgetNotifiedAt.delete(key + ':budget');
702
+ }
699
703
  }
700
704
  // #192: drop RPM windows for refs that no longer belong to any pool
701
705
  for (const st of poolState.values()) {
@@ -845,12 +849,11 @@ export function apply(ctx, config = {}) {
845
849
  // the resolve patch hands out the next key on each dispatch.
846
850
  function rotate(options, pool) {
847
851
  return (async function* () {
848
- const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
852
+ const runtime0 = buildRuntime();
853
+ const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
849
854
  let lastFailure = null;
850
855
  const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
851
856
  _rotateStartMs = reqStore.startMs;
852
-
853
- const runtime0 = buildRuntime();
854
857
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
855
858
  if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
856
859
  // #193: prefer least-loaded key within limit
@@ -936,8 +939,8 @@ export function apply(ctx, config = {}) {
936
939
  lastFailure = chunk;
937
940
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
938
941
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
939
- if (buildRuntime().switchNotify && activeRef) {
940
- notifySwitch(buildRuntime(), pool, {
942
+ if (switchNotify && activeRef) {
943
+ notifySwitch(runtime0, pool, {
941
944
  provider: options.provider,
942
945
  from: activeRef,
943
946
  code: String(code ?? 'UNKNOWN'),
@@ -948,6 +951,7 @@ export function apply(ctx, config = {}) {
948
951
  break;
949
952
  }
950
953
  // cost tracking if provider returns usage.cost
954
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
951
955
  if (chunk.usage?.cost != null && activeRef) {
952
956
  const c = Number(chunk.usage.cost);
953
957
  if (!isNaN(c)) {
@@ -955,18 +959,16 @@ export function apply(ctx, config = {}) {
955
959
  pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
956
960
  // #208: cost per day per key (mirrors usageDays) for budget checks
957
961
  if (!pool.state.costDays) pool.state.costDays = new Map();
958
- const cday = new Date().toISOString().slice(0, 10);
959
962
  const cMap = pool.state.costDays.get(activeRef) || new Map();
960
- cMap.set(cday, (cMap.get(cday) ?? 0) + c);
963
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
961
964
  pool.state.costDays.set(activeRef, cMap);
962
965
  }
963
966
  }
964
967
  // Usage by day (#119)
965
968
  if (activeRef) {
966
969
  if (!pool.state.usageDays) pool.state.usageDays = new Map();
967
- const day = new Date().toISOString().slice(0, 10);
968
970
  const dayMap = pool.state.usageDays.get(activeRef) || new Map();
969
- dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
971
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
970
972
  pool.state.usageDays.set(activeRef, dayMap);
971
973
  }
972
974
  // Per-model request detail (#121)
@@ -981,7 +983,6 @@ export function apply(ctx, config = {}) {
981
983
  // We do NOT re-run this (already successful) request — that would double-send.
982
984
  const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
983
985
  if (rate && activeRef) {
984
- const { rateLimitThreshold } = buildRuntime();
985
986
  if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
986
987
  const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
987
988
  recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
@@ -1010,8 +1011,8 @@ export function apply(ctx, config = {}) {
1010
1011
  pool.state.lastSwitchAt = Date.now();
1011
1012
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
1012
1013
  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, {
1014
+ if (switchNotify && activeRef) {
1015
+ notifySwitch(runtime0, pool, {
1015
1016
  provider: options.provider,
1016
1017
  from: activeRef,
1017
1018
  code: String(e?.code ?? 'TRANSPORT'),
@@ -1148,7 +1149,7 @@ export function apply(ctx, config = {}) {
1148
1149
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
1149
1150
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1150
1151
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1151
- totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
1152
+ totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
1152
1153
  // #225: aggregate p95 across the pool's keys
1153
1154
  p95: (() => {
1154
1155
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.37",
3
+ "version": "0.7.38",
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": [