@goodandready/dsh-key-rotation 0.7.25 → 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();
@@ -577,7 +635,7 @@ export function apply(ctx, config = {}) {
577
635
  for (const key of [...poolState.keys()]) {
578
636
  if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
579
637
  }
580
- 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 };
581
639
  }
582
640
 
583
641
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -687,6 +745,25 @@ export function apply(ctx, config = {}) {
687
745
  let lastFailure = null;
688
746
  _rotateStartMs = Date.now();
689
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
+ }
690
767
  for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
691
768
  let yielded = false;
692
769
  let switching = false;
@@ -702,6 +779,8 @@ export function apply(ctx, config = {}) {
702
779
  continue;
703
780
  }
704
781
 
782
+ const _pickedRef = pool.state.lastUsed;
783
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
705
784
  try {
706
785
  for await (const chunk of inner) {
707
786
  // Only actual content deltas lock the stream (no more rotation).
@@ -797,10 +876,12 @@ export function apply(ctx, config = {}) {
797
876
  yield chunk;
798
877
  }
799
878
  } catch (e) {
879
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
800
880
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
801
881
  return;
802
882
  }
803
883
 
884
+ if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
804
885
  if (switching) continue; // try the next key
805
886
  return; // clean end — served
806
887
  }
@@ -812,6 +893,24 @@ export function apply(ctx, config = {}) {
812
893
  // notify via extracted helper (see notifyExhaustion above)
813
894
  notifyExhaustion(buildRuntime(), pool, { provider: options.provider });
814
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
+
815
914
  yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
816
915
  })();
817
916
  }
@@ -1156,6 +1255,36 @@ export function apply(ctx, config = {}) {
1156
1255
  },
1157
1256
  }), 'dsh-key-rotation: incident-reset');
1158
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
+
1159
1288
  // Webhook test endpoint (#10): dry-run that validates webhookSender setup.
1160
1289
  ctx.effect(() => ctx.webServer.register({
1161
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.25",
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",