@goodandready/dsh-key-rotation 0.7.36 → 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,21 @@ 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
+
263
+ ### v0.7.37
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.
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.
266
+ - **Pure Local Candidate List in rotate()**: Generator uses an isolated local slice of keys (`attemptList`), eliminating shared mutations on `pool.weightedRefs`.
267
+ - **Automatic Quarantine Release on Probe Success**: Successful sandbox model tests via Settings UI (`/dsh-key-rotation/test`) automatically lift `failedUntil` and `brokenUntil` quarantine flags.
268
+ - **Long-Term Memory Compaction**: Wired `compactUsage(pool, 30, now)` into the periodic 30-second maintenance sweep to prevent memory growth on high-uptime servers.
269
+ - **Request-Scoped Latency Recording**: Replaced module-global start timestamp with context-scoped `startMs` for accurate p50/p95 latency metrics under concurrent load.
270
+
256
271
  ### v0.7.36
257
272
  - **Architecture de-bloat & hardening**: Removed 6 unused/overengineered modules (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) and dead route registrations.
258
273
  - **High-throughput buildRuntime memoization**: Eliminates per-token deep-cloning and schema validation on every streaming chunk.
package/README.ru.md CHANGED
@@ -253,6 +253,21 @@ 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
+
263
+ ### v0.7.37
264
+ - **Изоляция контекста запросов через AsyncLocalStorage**: Полная привязка активного ключа (`pickedRef`), времени старта и попыток к асинхронному контексту вызова через `node:async_hooks`. Устранена гонка, при которой параллельные запросы могли ошибочно штрафовать здоровый ключ соседа.
265
+ - **Failover при исключениях в потоке до первого чанка**: Устранено аварийное прерывание потока при транспортных сбоях (например, выброс HTTP 429 до отправки заголовков). Теперь блок перехвата проверяет `isSwitchableError` и прозрачно переключает поток на запасной ключ, если клиенту ещё не было отдано полезных данных.
266
+ - **Чистый локальный список кандидатов в rotate()**: Устранена прямая мутация разделяемого массива `pool.weightedRefs` во время выполнения ротации.
267
+ - **Автоматический вывод из карантина при успехе проверки**: Успешная проверка ключа через песочницу в настройках (`/dsh-key-rotation/test`) моментально сбрасывает флаги карантина (`failedUntil` и `brokenUntil`).
268
+ - **Регулярная компактизация памяти**: Функция `compactUsage(pool, 30, now)` подключена в 30-секундный фоновый таймер пула, предотвращая утечки памяти при непрерывной многомесячной работе.
269
+ - **Точные метрики задержки при параллельных вызовах**: Переход с глобальной переменной времени старта на контекстный замер `startMs` гарантирует достоверность p50/p95 latency под нагрузкой.
270
+
256
271
  ### v0.7.36
257
272
  - **Глубокий рефакторинг и деблоатинг**: Удалены 6 неиспользуемых модулей оверинжиниринга (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) и сопутствующие устаревшие эндпоинты.
258
273
  - **Мемоизация buildRuntime**: Устранено глубокое клонирование и повторный парсинг схемы на каждом токене/чанге стрима.
package/README.zh.md CHANGED
@@ -214,6 +214,21 @@ 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
+
224
+ ### v0.7.37
225
+ - **通过 AsyncLocalStorage 隔离请求上下文**:使用 Node.js 的 `node:async_hooks` 将解析后的密钥 (`pickedRef`)、启动时间和重试严格限定在单个请求上下文内,彻底消除并发请求间的竞态条件与误罚。
226
+ - **流异常自动故障转移**:修复流在首个 token 返回前抛出传输异常(如 HTTP 429)直接终止的问题。若未发送内容块,现在会自动触发 `isSwitchableError` 并顺畅切换到备用密钥。
227
+ - **避免在 rotate() 中直接修改共享状态**:遍历候选列表改用纯净的局部切片,不再直接覆盖修改 `pool.weightedRefs`。
228
+ - **测试成功后自动解除隔离**:在设置界面通过沙箱成功验证密钥有效性后,自动清除 `failedUntil` 和 `brokenUntil` 惩罚标记。
229
+ - **定期内存压缩清理**:将 `compactUsage(pool, 30, now)` 接入 30 秒后台巡检定时器,杜绝超长运行环境下的内存增长。
230
+ - **并发环境下的精确延迟统计**:为每个请求独立计时,避免全局变量被并发请求覆盖导致 p50/p95 延迟失真。
231
+
217
232
  ### v0.7.36
218
233
  - **架构精简与稳定性加固**:移除 6 个过度设计的模块(`shadow`、`incident`、`agent-budget`、`region`、`canary`、`maintenance`)与废弃端点。
219
234
  - **buildRuntime 高性能记忆化**:消除每个流式 token/chunk 上的深拷贝与模式重解析开销。
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@
32
32
  // cooldownMs: number key cooldown after a switchable failure
33
33
  // providers: array [{ provider, keys: [envName, ...] }]
34
34
  // ─────────────────────────────────────────────────────────────────────────────
35
+ import { AsyncLocalStorage } from 'node:async_hooks';
35
36
  import Schema from '@deepseek-ai/schemastery';
36
37
  import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError, formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
37
38
 
@@ -61,7 +62,9 @@ import { nextQuotaReset } from './quota-window.js';
61
62
  import { QuotaStore } from './quota.js';
62
63
  import { WebhookSender } from './webhook.js';
63
64
  import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
64
- import { usageRows, usageCsv } from './usage-report.js';
65
+ import { usageRows, usageCsv, compactUsage } from './usage-report.js';
66
+
67
+ const dispatchStorage = new AsyncLocalStorage();
65
68
  import { findSecrets, looksLikeApiSecret } from './keycheck.js';
66
69
 
67
70
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
@@ -449,6 +452,9 @@ export function apply(ctx, config = {}) {
449
452
  }
450
453
  const n = sweepExpired(poolState, now);
451
454
  if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
455
+ for (const pool of buildRuntime().poolByRef.values()) {
456
+ compactUsage(pool, 30, now);
457
+ }
452
458
  // #207 expiry pre-warning + #208 cost budget - piggybacked on this timer,
453
459
  // deduped to one notification per key/window per day (shouldNotifyDaily).
454
460
  try {
@@ -689,7 +695,11 @@ export function apply(ctx, config = {}) {
689
695
 
690
696
  // auto-cleanup: remove poolState for providers that are now empty or removed
691
697
  for (const key of [...poolState.keys()]) {
692
- 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
+ }
693
703
  }
694
704
  // #192: drop RPM windows for refs that no longer belong to any pool
695
705
  for (const st of poolState.values()) {
@@ -761,6 +771,8 @@ export function apply(ctx, config = {}) {
761
771
  let hit = await original(candidate);
762
772
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
763
773
  pool.state.lastUsed = candidate;
774
+ const store = dispatchStorage.getStore();
775
+ if (store && store.pool === pool) store.pickedRef = candidate;
764
776
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
765
777
  pool.state.failedUntil.delete(candidate);
766
778
  if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
@@ -780,6 +792,8 @@ export function apply(ctx, config = {}) {
780
792
  const envVal = envValue(candidate);
781
793
  if (envVal !== undefined) {
782
794
  pool.state.lastUsed = candidate;
795
+ const store = dispatchStorage.getStore();
796
+ if (store && store.pool === pool) store.pickedRef = candidate;
783
797
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
784
798
  pool.state.failedUntil.delete(candidate);
785
799
  if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
@@ -817,13 +831,14 @@ export function apply(ctx, config = {}) {
817
831
  // Latency recording (#6): record successful llm/stream latency per ref.
818
832
  // ponytail: only the true success path (finish-chunk). Failures are not recorded.
819
833
  let _rotateStartMs = Date.now();
820
- function recordLatency(pool) {
834
+ function recordLatency(pool, reqStore) {
821
835
  try {
822
836
  const cfg = getConfig();
823
837
  if (!cfg || cfg.latencyEnabled === false) return;
824
- const ref = pool && pool.state && pool.state.lastUsed;
838
+ const ref = reqStore?.pickedRef ?? pool?.state?.lastUsed;
825
839
  if (!ref) return;
826
- const elapsed = Date.now() - _rotateStartMs;
840
+ const startMs = reqStore?.startMs ?? _rotateStartMs;
841
+ const elapsed = Date.now() - startMs;
827
842
  if (!Number.isFinite(elapsed) || elapsed < 0) return;
828
843
  latencyHistogram.record(ref, elapsed);
829
844
  } catch (_) { /* ponytail: never crash */ }
@@ -834,14 +849,15 @@ export function apply(ctx, config = {}) {
834
849
  // the resolve patch hands out the next key on each dispatch.
835
850
  function rotate(options, pool) {
836
851
  return (async function* () {
837
- const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
838
- let lastFailure = null;
839
- _rotateStartMs = Date.now();
840
-
841
852
  const runtime0 = buildRuntime();
853
+ const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
854
+ let lastFailure = null;
855
+ const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
856
+ _rotateStartMs = reqStore.startMs;
857
+ let attemptList = (pool.weightedRefs ?? pool.refs).slice();
842
858
  if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
843
859
  // #193: prefer least-loaded key within limit
844
- const available = (pool.weightedRefs ?? pool.refs).filter((r) => {
860
+ const available = attemptList.filter((r) => {
845
861
  const fu = pool.state.failedUntil.get(r) ?? 0;
846
862
  if (fu > Date.now()) return false;
847
863
  const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
@@ -849,30 +865,54 @@ export function apply(ctx, config = {}) {
849
865
  return true;
850
866
  });
851
867
  const preferred = concurrencyTracker.pickLeastLoaded(available);
852
- if (preferred && (pool.weightedRefs ?? pool.refs)[0] !== preferred) {
853
- // Move preferred to front of the attempt list
854
- const list = (pool.weightedRefs ?? pool.refs).slice();
868
+ if (preferred && attemptList[0] !== preferred) {
869
+ const list = attemptList.slice();
855
870
  const i = list.indexOf(preferred);
856
871
  if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
857
- pool.weightedRefs = list;
872
+ attemptList = list;
858
873
  }
859
874
  }
860
- for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
875
+
876
+ const penalizeRef = (targetRef, errCode, errMsg) => {
877
+ if (!targetRef) return;
878
+ const _retry = parseRetryAfter(errMsg);
879
+ const _base = pool.cooldownMs ?? cooldownMs;
880
+ const _max = pool.maxCooldownMs ?? maxCooldownMs;
881
+ const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
882
+ const _b = recordFailure(pool, targetRef, Date.now(), _effBase, _max);
883
+ pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
884
+ if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
885
+ if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
886
+ const _cStr = String(errCode ?? '');
887
+ if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
888
+ const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
889
+ pool.state.authFailCounts.set(targetRef, _c2);
890
+ if (_c2 >= 3) {
891
+ pool.state.brokenUntil.set(targetRef, Date.now() + 86400000 * 30);
892
+ pool.state.failedUntil.set(targetRef, Date.now() + 86400000 * 30);
893
+ }
894
+ } else {
895
+ pool.state.authFailCounts.delete(targetRef);
896
+ }
897
+ };
898
+
899
+ for (let attempt = 0; attempt < attemptList.length; attempt++) {
861
900
  let yielded = false;
862
901
  let switching = false;
863
902
  let inner;
864
903
  try {
865
904
  // mark the internal dispatch so the interceptor does not re-rotate
866
- inner = ctx.llm.stream({ ...options, [MARKER]: true });
905
+ inner = dispatchStorage.run(reqStore, () => ctx.llm.stream({ ...options, [MARKER]: true }));
867
906
  } catch (e) {
868
- if (pool.state.lastUsed) { const _retry = parseRetryAfter(String(e?.message ?? '')); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); const _code = String(e?.code ?? ''); if (_code === 'AUTH' || /auth/i.test(String(e?.message ?? ''))) { const _c = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c); if (_c >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
907
+ const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
908
+ penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
869
909
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
870
910
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
871
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
911
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
872
912
  continue;
873
913
  }
874
914
 
875
- const _pickedRef = pool.state.lastUsed;
915
+ const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
876
916
  if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
877
917
  try {
878
918
  for await (const chunk of inner) {
@@ -890,39 +930,19 @@ export function apply(ctx, config = {}) {
890
930
  const message = failure?.message ?? '';
891
931
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
892
932
  const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
933
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
893
934
  if (switchable) {
894
- if (pool.state.lastUsed) {
895
- const _retry = parseRetryAfter(message);
896
- const _base = pool.cooldownMs ?? cooldownMs;
897
- const _max = pool.maxCooldownMs ?? maxCooldownMs;
898
- const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
899
- const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max);
900
- pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b);
901
- // authFailCounts/brokenUntil: lazy-init if state was created by an older plugin version
902
- if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
903
- if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
904
- const _code2 = String(code ?? '');
905
- if (_code2 === 'AUTH' || /auth/i.test(message)) {
906
- const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1;
907
- pool.state.authFailCounts.set(pool.state.lastUsed, _c2);
908
- if (_c2 >= 3) {
909
- pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
910
- pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
911
- }
912
- } else {
913
- pool.state.authFailCounts.delete(pool.state.lastUsed);
914
- }
915
- }
935
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message);
916
936
  pool.state.switches = (pool.state.switches ?? 0) + 1;
917
937
  pool.state.lastReason = String(code ?? 'UNKNOWN');
918
938
  pool.state.lastSwitchAt = Date.now();
919
939
  lastFailure = chunk;
920
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
940
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
921
941
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
922
- if (buildRuntime().switchNotify && pool.state.lastUsed) {
923
- notifySwitch(buildRuntime(), pool, {
942
+ if (switchNotify && activeRef) {
943
+ notifySwitch(runtime0, pool, {
924
944
  provider: options.provider,
925
- from: pool.state.lastUsed,
945
+ from: activeRef,
926
946
  code: String(code ?? 'UNKNOWN'),
927
947
  at: pool.state.lastSwitchAt,
928
948
  });
@@ -931,59 +951,76 @@ export function apply(ctx, config = {}) {
931
951
  break;
932
952
  }
933
953
  // cost tracking if provider returns usage.cost
934
- if (chunk.usage?.cost != null && pool.state.lastUsed) {
954
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
955
+ if (chunk.usage?.cost != null && activeRef) {
935
956
  const c = Number(chunk.usage.cost);
936
957
  if (!isNaN(c)) {
937
958
  if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
938
- pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
959
+ pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
939
960
  // #208: cost per day per key (mirrors usageDays) for budget checks
940
961
  if (!pool.state.costDays) pool.state.costDays = new Map();
941
- const cday = new Date().toISOString().slice(0, 10);
942
- const cMap = pool.state.costDays.get(pool.state.lastUsed) || new Map();
943
- cMap.set(cday, (cMap.get(cday) ?? 0) + c);
944
- pool.state.costDays.set(pool.state.lastUsed, cMap);
962
+ const cMap = pool.state.costDays.get(activeRef) || new Map();
963
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
964
+ pool.state.costDays.set(activeRef, cMap);
945
965
  }
946
966
  }
947
967
  // Usage by day (#119)
948
- if (pool.state.lastUsed) {
968
+ if (activeRef) {
949
969
  if (!pool.state.usageDays) pool.state.usageDays = new Map();
950
- const day = new Date().toISOString().slice(0, 10);
951
- const dayMap = pool.state.usageDays.get(pool.state.lastUsed) || new Map();
952
- dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
953
- pool.state.usageDays.set(pool.state.lastUsed, dayMap);
970
+ const dayMap = pool.state.usageDays.get(activeRef) || new Map();
971
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
972
+ pool.state.usageDays.set(activeRef, dayMap);
954
973
  }
955
974
  // Per-model request detail (#121)
956
- if (pool.state.lastUsed && options.model) {
975
+ if (activeRef && options.model) {
957
976
  if (!pool.state.byModel) pool.state.byModel = new Map();
958
- let byRef = pool.state.byModel.get(pool.state.lastUsed);
959
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(pool.state.lastUsed, byRef); }
977
+ let byRef = pool.state.byModel.get(activeRef);
978
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
960
979
  byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
961
980
  }
962
981
  // Proactive rate-limit (#115): if response headers say this key is near
963
982
  // its quota, cool it down so the NEXT request starts on a different key.
964
983
  // We do NOT re-run this (already successful) request — that would double-send.
965
984
  const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
966
- if (rate && pool.state.lastUsed) {
967
- const { rateLimitThreshold } = buildRuntime();
985
+ if (rate && activeRef) {
968
986
  if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
969
987
  const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
970
- recordFailure(pool, pool.state.lastUsed, Date.now(), cool, pool.maxCooldownMs);
971
- pushEvent(pool, pool.state.lastUsed, 'RATE_LIMIT', cool);
972
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
988
+ recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
989
+ pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
990
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
973
991
  }
974
992
  }
975
993
  // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
976
- if (rate && pool.state.lastUsed && Number.isFinite(rate.remaining)) {
977
- quotaStore.set(pool.state.lastUsed, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
994
+ if (rate && activeRef && Number.isFinite(rate.remaining)) {
995
+ quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
978
996
  }
979
997
  yield chunk;
980
- recordLatency(pool);
998
+ recordLatency(pool, reqStore);
981
999
  return;
982
1000
  }
983
1001
  yield chunk;
984
1002
  }
985
1003
  } catch (e) {
986
1004
  if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
1005
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
1006
+ const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
1007
+ if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
1008
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
1009
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
1010
+ pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
1011
+ pool.state.lastSwitchAt = Date.now();
1012
+ lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
1013
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
1014
+ if (switchNotify && activeRef) {
1015
+ notifySwitch(runtime0, pool, {
1016
+ provider: options.provider,
1017
+ from: activeRef,
1018
+ code: String(e?.code ?? 'TRANSPORT'),
1019
+ at: pool.state.lastSwitchAt,
1020
+ });
1021
+ }
1022
+ continue; // Failover to next key!
1023
+ }
987
1024
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
988
1025
  return;
989
1026
  }
@@ -1112,7 +1149,7 @@ export function apply(ctx, config = {}) {
1112
1149
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
1113
1150
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1114
1151
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1115
- 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; })(),
1116
1153
  // #225: aggregate p95 across the pool's keys
1117
1154
  p95: (() => {
1118
1155
  const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
@@ -1435,6 +1472,16 @@ export function apply(ctx, config = {}) {
1435
1472
  const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
1436
1473
  const cached = { ...result, at: Date.now() };
1437
1474
  lastTestCache.set(ref, cached);
1475
+ if (cached.ok) {
1476
+ for (const st of poolState.values()) {
1477
+ if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
1478
+ st.failedUntil?.delete(ref);
1479
+ st.failCounts?.delete(ref);
1480
+ st.authFailCounts?.delete(ref);
1481
+ st.brokenUntil?.delete(ref);
1482
+ }
1483
+ }
1484
+ }
1438
1485
  json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
1439
1486
  return;
1440
1487
  }
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,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.36",
3
+ "version": "0.7.38",
4
+ "packageManager": "pnpm@10.33.2",
4
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.",
5
6
  "keywords": [
6
7
  "deepseek-harness",
@@ -55,4 +56,4 @@
55
56
  "scripts": {
56
57
  "test": "node --test test/*.test.js test/*.test.mjs"
57
58
  }
58
- }
59
+ }