@goodandready/dsh-key-rotation 0.8.42 → 0.8.43

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.43 - 2026-10-01
4
+
5
+ ### Fixed
6
+ - **Sanitized GitHub mirror and provenance workflow documentation (#425)**:
7
+ - Updated `docs/deployment/github-npm-provenance.md` to accurately document the Gitea-first development workflow and sanitized GitHub mirror export (`./scripts/publish-github.sh --push <tag>`), eliminating contradictions regarding direct GitHub PRs and external CI triggers.
8
+ - **LatencyHistogram configuration from latencyWindow setting (#426)**:
9
+ - Implemented dynamic `reconfigure({ window })` on `LatencyHistogram` (`lib/histogram.js`) and wired `cfg.latencyWindow` from configuration into `buildRuntime()` (`lib/index.js`), ensuring the configured latency sampling window is respected at runtime.
10
+ - **Auto-unbreak quarantine separation and safety checks (#427)**:
11
+ - Updated `lib/rotate.js` to place keys into a recoverable 30-minute quarantine (`brokenUntil`) after 3 consecutive non-auth failures while protecting rate-limited/quota-exhausted keys from quarantine pollution.
12
+ - Updated `lib/heal.js` to strictly skip permanently revoked (`revokedRefs`) and paused (`pausedRefs`) credentials during auto-unbreak background probing, preventing probing or accidental reactivation of revoked credentials.
13
+ - **Webhook Alert Digest pipeline integration (#428)**:
14
+ - Wired `AlertDebouncer` into the production notification pipeline in `lib/index.js`, batching rapid alert bursts into consolidated digest payloads before webhook dispatch as documented.
15
+
3
16
  ## 0.8.42 - 2026-10-01
4
17
 
5
18
  ### Fixed
package/lib/heal.js CHANGED
@@ -54,6 +54,7 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), {
54
54
  if (!bu || bu.size === 0) continue;
55
55
  const brokenRefs = [];
56
56
  for (const [ref, until] of bu.entries()) {
57
+ if (pool.state.revokedRefs?.has(ref) || pool.state.pausedRefs?.has(ref)) continue;
57
58
  if (Number.isFinite(until) && until > now) {
58
59
  brokenRefs.push(ref);
59
60
  }
package/lib/histogram.js CHANGED
@@ -4,11 +4,43 @@
4
4
  export const LATENCY_DEFAULT_WINDOW = 200;
5
5
 
6
6
  export class LatencyHistogram {
7
- constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
7
+ constructor(opts = {}) {
8
+ const windowVal = typeof opts === 'number' ? opts : opts?.window;
9
+ this.reconfigure({ window: windowVal });
10
+ }
11
+
12
+ get window() {
13
+ return this._window;
14
+ }
15
+
16
+ reconfigure({ window = LATENCY_DEFAULT_WINDOW } = {}) {
8
17
  const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
18
+ if (this._window === w && this._buffers) return;
19
+
20
+ if (!this._buffers) {
21
+ this._window = w;
22
+ this._buffers = new Map(); // ref -> { buf, head, count }
23
+ this._lastAt = new Map(); // ref -> epochMs
24
+ return;
25
+ }
26
+
27
+ // Resize existing ring buffers to new length w
28
+ for (const [ref, entry] of this._buffers.entries()) {
29
+ const samples = [];
30
+ const start = entry.count < entry.buf.length ? 0 : entry.head;
31
+ for (let i = 0; i < entry.count; i++) {
32
+ samples.push(entry.buf[(start + i) % entry.buf.length]);
33
+ }
34
+ const newSamples = samples.slice(-w);
35
+ const newBuf = new Float64Array(w);
36
+ for (let i = 0; i < newSamples.length; i++) {
37
+ newBuf[i] = newSamples[i];
38
+ }
39
+ entry.buf = newBuf;
40
+ entry.head = newSamples.length % w;
41
+ entry.count = newSamples.length;
42
+ }
9
43
  this._window = w;
10
- this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
11
- this._lastAt = new Map(); // ref -> epochMs of last sample
12
44
  }
13
45
 
14
46
  record(ref, ms) {
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  import { LatencyHistogram } from './histogram.js';
21
21
  import { ConcurrencyTracker } from './concurrency.js';
22
22
  import { QuotaStore } from './quota.js';
23
- import { WebhookSender } from './webhook.js';
23
+ import { WebhookSender, AlertDebouncer } from './webhook.js';
24
24
  import { compactUsage } from './usage-report.js';
25
25
  import { json, handleConfigBridge } from './http-bridge.js';
26
26
  import { createRotate } from './rotate.js';
@@ -80,7 +80,8 @@ const concurrencyTracker = new ConcurrencyTracker();
80
80
 
81
81
  let moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
82
82
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
83
- let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
83
+ const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
84
+ let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
84
85
 
85
86
  let getConfig = () => null;
86
87
  function verboseLoggingOn() {
@@ -270,6 +271,9 @@ export function apply(ctx, config = {}) {
270
271
  openMs: cfg.circuitBreakerOpenMs ?? 30000,
271
272
  halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
272
273
  });
274
+ if (cfg.latencyWindow && latencyHistogram.window !== cfg.latencyWindow) {
275
+ latencyHistogram.reconfigure({ window: cfg.latencyWindow });
276
+ }
273
277
  const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
274
278
  const cooldownMs = cfg.cooldownMs ?? 60000;
275
279
  const maxCooldownMs = cfg.maxCooldownMs;
package/lib/rotate.js CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  formatModelQuotaExhaustion,
27
27
  } from './model-quota.js';
28
28
 
29
+ import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
30
+
29
31
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
30
32
 
31
33
  export async function* preserveDispatchContext(iterable, storage, store) {
@@ -186,6 +188,16 @@ export function createRotate(deps) {
186
188
  logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently REVOKED on auth failure`);
187
189
  } else {
188
190
  pool.state.authFailCounts.delete(targetRef);
191
+ if (!isQuota) {
192
+ const _fc = (pool.state.failCounts.get(targetRef) ?? 0) + 1;
193
+ pool.state.failCounts.set(targetRef, _fc);
194
+ if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
195
+ const quarantineMs = 1800000;
196
+ pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
197
+ pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
198
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
199
+ }
200
+ }
189
201
  }
190
202
  };
191
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.42",
3
+ "version": "0.8.43",
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": [