@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 +56 -0
- package/lib/cascade.js +39 -0
- package/lib/concurrency.js +72 -0
- package/lib/index.js +174 -6
- package/lib/quota-window.js +45 -0
- package/package.json +1 -1
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(),
|
|
496
|
-
|
|
497
|
-
|
|
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) {
|
|
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))
|
|
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.
|
|
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",
|