@goodandready/dsh-key-rotation 0.7.24 → 0.7.26

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/lib/canary.js ADDED
@@ -0,0 +1,56 @@
1
+ // canary.js — canary probing before releasing a key from cooldown (issue #196).
2
+
3
+ export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
+ export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
+
6
+ export class CanaryProber {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+ if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
+ this._runner = opts.sandboxRunner;
11
+ this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
+ this._results = new Map();
13
+ this._inProgress = new Set();
14
+ }
15
+
16
+ get intervalMs() { return this._intervalMs; }
17
+
18
+ async probe(ref, key) {
19
+ if (!ref || this._inProgress.has(ref)) return null;
20
+ this._inProgress.add(ref);
21
+ try {
22
+ const result = await this._runner.probeModels(ref, key);
23
+ this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
24
+ return result;
25
+ } catch (e) {
26
+ return { ok: false, code: 'error', at: Date.now() };
27
+ } finally {
28
+ this._inProgress.delete(ref);
29
+ }
30
+ }
31
+
32
+ lastResult(ref) {
33
+ return this._results.get(ref) || null;
34
+ }
35
+
36
+ isHealthy(ref) {
37
+ const r = this._results.get(ref);
38
+ return Boolean(r && r.ok);
39
+ }
40
+
41
+ clear(ref) {
42
+ if (ref) {
43
+ this._results.delete(ref);
44
+ this._inProgress.delete(ref);
45
+ } else {
46
+ this._results.clear();
47
+ this._inProgress.clear();
48
+ }
49
+ }
50
+
51
+ snapshot() {
52
+ const out = {};
53
+ for (const [k, v] of this._results) out[k] = v;
54
+ return out;
55
+ }
56
+ }
package/lib/cascade.js ADDED
@@ -0,0 +1,39 @@
1
+ // cascade.js — cross-provider failover cascade (issue #194).
2
+ // ponytail: minimal — pick fallback provider from a RegionMap-like config.
3
+
4
+ export const CASCADE_MAX_DEPTH = 1;
5
+
6
+ export function pickCascadeFallback(provider, cfg, pools) {
7
+ const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
+ for (const entry of list) {
9
+ const fb = typeof entry === 'string' ? { provider: entry } : entry;
10
+ if (!fb || !fb.provider || fb.provider === provider) continue;
11
+ const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
12
+ if (!pool) continue;
13
+ const now = Date.now();
14
+ let healthy = 0;
15
+ for (const ref of pool.refs) {
16
+ const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
+ if (failedUntil > now) continue;
18
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
19
+ if (exp !== undefined && now >= exp) continue;
20
+ healthy += 1;
21
+ }
22
+ if (healthy === 0) continue;
23
+ return { provider: fb.provider, pool, model: fb.model || null };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function hasHealthyKey(pool, now) {
29
+ now = now || Date.now();
30
+ if (!pool || !Array.isArray(pool.refs)) return false;
31
+ for (const ref of pool.refs) {
32
+ const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
33
+ if (failedUntil > now) continue;
34
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
35
+ if (exp !== undefined && now >= exp) continue;
36
+ return true;
37
+ }
38
+ return false;
39
+ }
@@ -0,0 +1,72 @@
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
+
3
+ export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
+
6
+ export class ConcurrencyTracker {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+ const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
10
+ this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
11
+ this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
12
+ this._inFlight = new Map();
13
+ }
14
+
15
+ isEnabled() { return this._limit > 0; }
16
+ get limit() { return this._limit; }
17
+
18
+ acquire(ref, now) {
19
+ now = now || Date.now();
20
+ if (!this.isEnabled()) return true;
21
+ let e = this._inFlight.get(ref);
22
+ if (!e) {
23
+ e = { count: 0, lastAcquired: now };
24
+ this._inFlight.set(ref, e);
25
+ }
26
+ if (now - e.lastAcquired > this._staleMs) {
27
+ e.count = 0;
28
+ }
29
+ if (e.count >= this._limit) return false;
30
+ e.count += 1;
31
+ e.lastAcquired = now;
32
+ return true;
33
+ }
34
+
35
+ release(ref, now) {
36
+ now = now || Date.now();
37
+ const e = this._inFlight.get(ref);
38
+ if (!e) return;
39
+ e.count = Math.max(0, e.count - 1);
40
+ e.lastAcquired = now;
41
+ }
42
+
43
+ snapshot() {
44
+ const out = {};
45
+ for (const [k, v] of this._inFlight) out[k] = { count: v.count };
46
+ return out;
47
+ }
48
+
49
+ pickLeastLoaded(candidates, now) {
50
+ now = now || Date.now();
51
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
52
+ let best = null;
53
+ let bestCount = Infinity;
54
+ for (const ref of candidates) {
55
+ const e = this._inFlight.get(ref);
56
+ const count = e ? e.count : 0;
57
+ if (this.isEnabled() && count >= this._limit) continue;
58
+ if (count < bestCount) {
59
+ best = ref;
60
+ bestCount = count;
61
+ }
62
+ }
63
+ return best;
64
+ }
65
+
66
+ clear(ref) {
67
+ if (ref) this._inFlight.delete(ref);
68
+ else this._inFlight.clear();
69
+ }
70
+
71
+ get size() { return this._inFlight.size; }
72
+ }
package/lib/index.js CHANGED
@@ -55,9 +55,14 @@ const REGIONS_PATH = '/dsh-key-rotation/regions';
55
55
  const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
56
56
  const SHADOW_PATH = '/dsh-key-rotation/shadow';
57
57
  const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
58
+ const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
58
59
  import { LastTestCache, SandboxRunner } from './sandbox.js';
59
60
  import { healIdleCooldowns } from './heal.js';
60
61
  import { LatencyHistogram } from './histogram.js';
62
+ import { pickCascadeFallback } from './cascade.js';
63
+ import { ConcurrencyTracker } from './concurrency.js';
64
+ import { nextQuotaReset } from './quota-window.js';
65
+ import { CanaryProber } from './canary.js';
61
66
  import { QuotaStore } from './quota.js';
62
67
  import { AgentBudget } from './agent-budget.js';
63
68
  import { RegionMap } from './region.js';
@@ -106,6 +111,8 @@ function ensureIncidentReporter() {
106
111
  }
107
112
  const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
108
113
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
114
+ const concurrencyTracker = new ConcurrencyTracker();
115
+ let canaryProber = null;
109
116
  function ensureSandboxRunner(ctx) {
110
117
  if (sandboxRunner) return sandboxRunner;
111
118
  // provider id -> baseUrl (stripped of trailing /) for fetch /models probe
@@ -158,6 +165,17 @@ export const Config = Schema.object({
158
165
  incidentGitHubToken: Schema.string().default(''),
159
166
  incidentGitHubBaseUrl: Schema.string().default(''),
160
167
  incidentThreshold: Schema.number().default(5),
168
+ concurrencyLimit: Schema.number().default(0),
169
+ canaryProbingEnabled: Schema.boolean().default(false),
170
+ canaryIntervalMs: Schema.number().default(30000),
171
+ cascade: Schema.array(Schema.object({
172
+ provider: Schema.string().required(),
173
+ model: Schema.string(),
174
+ })).default([]),
175
+ quotaResetWindow: Schema.object({
176
+ type: Schema.string().default('midnight_utc'),
177
+ hour: Schema.number().default(0),
178
+ }),
161
179
  rateLimitThreshold: Schema.number().default(0.1),
162
180
  providers: Schema.array(Schema.object({
163
181
  provider: Schema.string().required(),
@@ -337,6 +355,41 @@ export function apply(ctx, config = {}) {
337
355
  // interval, low cost; skipped when selfHealCooldown is disabled in config.
338
356
  // ponytail: keep handle on the same ctx via closure so buildRuntime() reads
339
357
  // fresh config on every tick. Naive but correct: 60s cadence is cheap.
358
+ // #196: canary probing before key release from cooldown.
359
+ // Every canaryIntervalMs, probe refs that are in cooldown and close to expiry.
360
+ let canaryTimer = null;
361
+ const startCanary = () => {
362
+ const cfg = getConfig();
363
+ if (!cfg || !cfg.canaryProbingEnabled) return;
364
+ if (canaryTimer) return;
365
+ canaryTimer = setInterval(() => {
366
+ try {
367
+ const c = getConfig();
368
+ if (!c || !c.canaryProbingEnabled) return;
369
+ const runner = ensureSandboxRunner();
370
+ if (!runner) return;
371
+ if (!canaryProber) {
372
+ canaryProber = new CanaryProber({ sandboxRunner: runner, intervalMs: c.canaryIntervalMs });
373
+ }
374
+ const providers = Array.isArray(c.providers) ? c.providers : [];
375
+ for (const p of providers) {
376
+ const pool = buildRuntime().providerToPool.get(p.provider);
377
+ if (!pool) continue;
378
+ for (const ref of pool.refs) {
379
+ const until = pool.state.failedUntil.get(ref) ?? 0;
380
+ const now = Date.now();
381
+ // Probe refs in cooldown whose expiry is within canaryIntervalMs of now
382
+ if (until > now && until - now < (c.canaryIntervalMs ?? 30000)) {
383
+ canaryProber.probe(ref, ref);
384
+ }
385
+ }
386
+ }
387
+ } catch (_) { /* ponytail: never crash the timer */ }
388
+ }, cfg.canaryIntervalMs ?? 30000);
389
+ if (typeof canaryTimer.unref === 'function') canaryTimer.unref();
390
+ };
391
+ startCanary();
392
+
340
393
  const selfHealTimer = setInterval(() => {
341
394
  const cfg = getConfig();
342
395
  if (!cfg || cfg.selfHealCooldown === false) return;
@@ -478,6 +531,11 @@ export function apply(ctx, config = {}) {
478
531
  const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
479
532
  const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
480
533
  const incidentThreshold = cfg.incidentThreshold ?? 5;
534
+ const concurrencyLimit = cfg.concurrencyLimit ?? 0;
535
+ const canaryProbingEnabled = cfg.canaryProbingEnabled ?? false;
536
+ const canaryIntervalMs = cfg.canaryIntervalMs ?? 30000;
537
+ const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
538
+ const quotaResetWindow = cfg.quotaResetWindow || null;
481
539
 
482
540
  // ref -> pool (every key env of every configured provider)
483
541
  const poolByRef = new Map();
@@ -492,9 +550,24 @@ export function apply(ctx, config = {}) {
492
550
  let st = poolState.get(base);
493
551
  if (!st) {
494
552
  st = {
495
- failedUntil: new Map(), failCounts: new Map(), pointer: 0, lastUsed: undefined,
496
- switches: 0, lastReason: undefined, lastSwitchAt: undefined,
497
- lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(), byModel: new Map(), usageDays: new Map(),
553
+ failedUntil: new Map(),
554
+ failCounts: new Map(),
555
+ authFailCounts: new Map(),
556
+ brokenUntil: new Map(),
557
+ costPerKey: new Map(),
558
+ lastUsedAt: new Map(),
559
+ usageCounts: new Map(),
560
+ byModel: new Map(),
561
+ usageDays: new Map(),
562
+ quotaWindows: new Map(),
563
+ pointer: 0,
564
+ lastUsed: undefined,
565
+ switches: 0,
566
+ lastReason: undefined,
567
+ lastSwitchAt: undefined,
568
+ lastExhaustionAt: undefined,
569
+ exhaustionCount: 0,
570
+ events: [],
498
571
  };
499
572
  poolState.set(base, st);
500
573
  }
@@ -562,7 +635,7 @@ export function apply(ctx, config = {}) {
562
635
  for (const key of [...poolState.keys()]) {
563
636
  if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
564
637
  }
565
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
638
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
566
639
  }
567
640
 
568
641
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -672,6 +745,25 @@ export function apply(ctx, config = {}) {
672
745
  let lastFailure = null;
673
746
  _rotateStartMs = Date.now();
674
747
 
748
+ const runtime0 = buildRuntime();
749
+ if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
750
+ // #193: prefer least-loaded key within limit
751
+ const available = (pool.weightedRefs ?? pool.refs).filter((r) => {
752
+ const fu = pool.state.failedUntil.get(r) ?? 0;
753
+ if (fu > Date.now()) return false;
754
+ const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
755
+ if (exp !== undefined && Date.now() >= exp) return false;
756
+ return true;
757
+ });
758
+ const preferred = concurrencyTracker.pickLeastLoaded(available);
759
+ if (preferred && (pool.weightedRefs ?? pool.refs)[0] !== preferred) {
760
+ // Move preferred to front of the attempt list
761
+ const list = (pool.weightedRefs ?? pool.refs).slice();
762
+ const i = list.indexOf(preferred);
763
+ if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
764
+ pool.weightedRefs = list;
765
+ }
766
+ }
675
767
  for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
676
768
  let yielded = false;
677
769
  let switching = false;
@@ -687,6 +779,8 @@ export function apply(ctx, config = {}) {
687
779
  continue;
688
780
  }
689
781
 
782
+ const _pickedRef = pool.state.lastUsed;
783
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
690
784
  try {
691
785
  for await (const chunk of inner) {
692
786
  // Only actual content deltas lock the stream (no more rotation).
@@ -705,7 +799,28 @@ export function apply(ctx, config = {}) {
705
799
  const switchable = !yielded && kind === 'error' &&
706
800
  (effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
707
801
  if (switchable) {
708
- if (pool.state.lastUsed) { const _retry = parseRetryAfter(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, code ?? 'UNKNOWN', _b); const _code2 = String(code ?? ''); if (_code2 === 'AUTH' || /auth/i.test(message)) { const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c2); if (_c2 >= 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); } }
802
+ if (pool.state.lastUsed) {
803
+ const _retry = parseRetryAfter(message);
804
+ const _base = pool.cooldownMs ?? cooldownMs;
805
+ const _max = pool.maxCooldownMs ?? maxCooldownMs;
806
+ const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
807
+ const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max);
808
+ pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b);
809
+ // authFailCounts/brokenUntil: lazy-init if state was created by an older plugin version
810
+ if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
811
+ if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
812
+ const _code2 = String(code ?? '');
813
+ if (_code2 === 'AUTH' || /auth/i.test(message)) {
814
+ const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1;
815
+ pool.state.authFailCounts.set(pool.state.lastUsed, _c2);
816
+ if (_c2 >= 3) {
817
+ pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
818
+ pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
819
+ }
820
+ } else {
821
+ pool.state.authFailCounts.delete(pool.state.lastUsed);
822
+ }
823
+ }
709
824
  pool.state.switches = (pool.state.switches ?? 0) + 1;
710
825
  pool.state.lastReason = String(code ?? 'UNKNOWN');
711
826
  pool.state.lastSwitchAt = Date.now();
@@ -717,7 +832,10 @@ export function apply(ctx, config = {}) {
717
832
  // cost tracking if provider returns usage.cost
718
833
  if (chunk.usage?.cost != null && pool.state.lastUsed) {
719
834
  const c = Number(chunk.usage.cost);
720
- if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
835
+ if (!isNaN(c)) {
836
+ if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
837
+ pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
838
+ }
721
839
  }
722
840
  // Usage by day (#119)
723
841
  if (pool.state.lastUsed) {
@@ -758,10 +876,12 @@ export function apply(ctx, config = {}) {
758
876
  yield chunk;
759
877
  }
760
878
  } catch (e) {
879
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
761
880
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
762
881
  return;
763
882
  }
764
883
 
884
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
765
885
  if (switching) continue; // try the next key
766
886
  return; // clean end — served
767
887
  }
@@ -773,6 +893,24 @@ export function apply(ctx, config = {}) {
773
893
  // notify via extracted helper (see notifyExhaustion above)
774
894
  notifyExhaustion(buildRuntime(), pool, { provider: options.provider });
775
895
 
896
+ // #194: cross-provider cascade failover
897
+ const runtime = buildRuntime();
898
+ if (Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
899
+ const pools = runtime.providerToPool;
900
+ const fb = pickCascadeFallback(options.provider, runtime, pools);
901
+ if (fb && fb.pool && fb.pool !== pool) {
902
+ console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
903
+ pool.state.lastReason = 'CASCADE';
904
+ pool.state.lastSwitchAt = Date.now();
905
+ // Re-dispatch on the fallback pool (depth-1 via marker check)
906
+ const innerCascade = rotate({ ...options, provider: fb.provider }, fb.pool);
907
+ for await (const chunk of innerCascade) {
908
+ yield chunk;
909
+ }
910
+ return;
911
+ }
912
+ }
913
+
776
914
  yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
777
915
  })();
778
916
  }
@@ -1117,6 +1255,36 @@ export function apply(ctx, config = {}) {
1117
1255
  },
1118
1256
  }), 'dsh-key-rotation: incident-reset');
1119
1257
 
1258
+ // #198: 1-click Health Matrix — parallel probe of all configured keys.
1259
+ ctx.effect(() => ctx.webServer.register({
1260
+ kind: 'exact',
1261
+ path: TEST_MATRIX_PATH,
1262
+ handler: async (req, res) => {
1263
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
1264
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: matrix is local-only' } }); return; }
1265
+ const cfg = getConfig();
1266
+ const runner = ensureSandboxRunner();
1267
+ if (!runner) { json(res, 500, { error: { code: 'no-runner', message: 'sandbox runner unavailable' } }); return; }
1268
+ const providers = Array.isArray(cfg?.providers) ? cfg.providers : [];
1269
+ const jobs = [];
1270
+ for (const p of providers) {
1271
+ for (const ref of (p.keys ?? [])) {
1272
+ if (typeof ref !== 'string' || !ref) continue;
1273
+ jobs.push((async () => {
1274
+ try {
1275
+ const probeResult = await runner.probeModels(ref, ref);
1276
+ return { provider: p.provider, ref, ok: probeResult.ok, code: probeResult.code, latencyMs: probeResult.latencyMs, modelsCount: probeResult.modelsCount ?? 0 };
1277
+ } catch (e) {
1278
+ return { provider: p.provider, ref, ok: false, code: 'error', latencyMs: 0, modelsCount: 0 };
1279
+ }
1280
+ })());
1281
+ }
1282
+ }
1283
+ const results = await Promise.all(jobs);
1284
+ json(res, 200, { at: Date.now(), total: results.length, ok: results.filter(r => r.ok).length, results });
1285
+ },
1286
+ }), 'dsh-key-rotation: test-matrix');
1287
+
1120
1288
  // Webhook test endpoint (#10): dry-run that validates webhookSender setup.
1121
1289
  ctx.effect(() => ctx.webServer.register({
1122
1290
  kind: 'exact',
@@ -0,0 +1,45 @@
1
+ // quota-window.js — calendar-based quota reset windows (issue #197).
2
+
3
+ export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'];
4
+
5
+ export function nextQuotaReset(quotaResetWindow, now) {
6
+ now = now || Date.now();
7
+ if (!quotaResetWindow || typeof quotaResetWindow !== 'object') return null;
8
+ const type = quotaResetWindow.type;
9
+ const hour = Number.isFinite(quotaResetWindow.hour) ? quotaResetWindow.hour : 0;
10
+
11
+ if (type === 'rolling_24h') {
12
+ const d = new Date(now);
13
+ d.setUTCHours(d.getUTCHours() + 24, 0, 0, 0);
14
+ return d.getTime();
15
+ }
16
+
17
+ if (type === 'midnight_utc') {
18
+ const d = new Date(now);
19
+ d.setUTCHours(hour, 0, 0, 0);
20
+ if (d.getTime() <= now) d.setUTCDate(d.getUTCDate() + 1);
21
+ return d.getTime();
22
+ }
23
+
24
+ if (type === 'midnight_pst') {
25
+ const PST_OFFSET_MS = 8 * 3600_000;
26
+ const shifted = now + PST_OFFSET_MS;
27
+ const d = new Date(shifted);
28
+ d.setUTCHours(hour, 0, 0, 0);
29
+ if (d.getTime() <= shifted) d.setUTCDate(d.getUTCDate() + 1);
30
+ return d.getTime() - PST_OFFSET_MS;
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ export function poolResetAt(pool, quotaResetWindow, now) {
37
+ return nextQuotaReset(quotaResetWindow, now);
38
+ }
39
+
40
+ export function isBlockedUntilReset(failedUntil, resetAt, now) {
41
+ now = now || Date.now();
42
+ if (!Number.isFinite(failedUntil)) return false;
43
+ if (resetAt === null || resetAt === undefined) return false;
44
+ return failedUntil >= resetAt;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.24",
3
+ "version": "0.7.26",
4
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.",
5
5
  "keywords": [
6
6
  "deepseek-harness",