@goodandready/dsh-key-rotation 0.7.34 → 0.7.36
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 +16 -1
- package/README.ru.md +16 -1
- package/README.zh.md +16 -1
- package/lib/client.js +95 -32
- package/lib/index.js +57 -292
- package/lib/pool.js +110 -1
- package/package.json +2 -3
- package/lib/agent-budget.js +0 -68
- package/lib/canary.js +0 -63
- package/lib/incident.js +0 -76
- package/lib/maintenance.js +0 -64
- package/lib/region.js +0 -50
- package/lib/shadow.js +0 -81
package/lib/index.js
CHANGED
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
35
|
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
-
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool } from './pool.js';
|
|
36
|
+
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
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
40
|
-
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
|
|
40
|
+
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage };
|
|
41
41
|
|
|
42
42
|
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
43
43
|
const NS = 'dsh-key-rotation';
|
|
@@ -52,27 +52,15 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
52
52
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
53
53
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
54
54
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
55
|
-
const AGENT_BUDGET_PATH = '/dsh-key-rotation/agent-budget';
|
|
56
|
-
const REGIONS_PATH = '/dsh-key-rotation/regions';
|
|
57
|
-
const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
|
|
58
|
-
const SHADOW_PATH = '/dsh-key-rotation/shadow';
|
|
59
|
-
const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
|
|
60
|
-
const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
|
|
61
55
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
62
56
|
import { healIdleCooldowns } from './heal.js';
|
|
63
57
|
import { LatencyHistogram } from './histogram.js';
|
|
64
58
|
import { pickCascadeFallback } from './cascade.js';
|
|
65
59
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
66
60
|
import { nextQuotaReset } from './quota-window.js';
|
|
67
|
-
import { CanaryProber } from './canary.js';
|
|
68
61
|
import { QuotaStore } from './quota.js';
|
|
69
|
-
import { AgentBudget } from './agent-budget.js';
|
|
70
|
-
import { RegionMap } from './region.js';
|
|
71
|
-
import { IncidentReporter } from './incident.js';
|
|
72
|
-
import { ShadowRouter } from './shadow.js';
|
|
73
62
|
import { WebhookSender } from './webhook.js';
|
|
74
63
|
import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
|
|
75
|
-
import { expiringSoon, shouldNotifyDaily, costForDay, budgetVerdict, costForWeek } from './maintenance.js';
|
|
76
64
|
import { usageRows, usageCsv } from './usage-report.js';
|
|
77
65
|
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
78
66
|
|
|
@@ -130,28 +118,12 @@ let lastTestCacheRunnerCtx = null;
|
|
|
130
118
|
const lastTestCache = new LastTestCache();
|
|
131
119
|
const latencyHistogram = new LatencyHistogram();
|
|
132
120
|
const quotaStore = new QuotaStore();
|
|
133
|
-
const agentBudget = new AgentBudget();
|
|
134
|
-
const regionMap = new RegionMap();
|
|
135
121
|
// Global config accessor safe against early initialization
|
|
136
122
|
let getConfig = () => null;
|
|
137
123
|
let getRuntime = () => null;
|
|
138
|
-
|
|
139
|
-
// IncidentReporter: lazily built when Config provides incidentGitHubToken + incidentGitHubBaseUrl.
|
|
140
|
-
// ponytail: never bake the token into source; repo is hardcoded (this plugin's home repo) but token is per-deploy.
|
|
141
|
-
let incidentReporter = null;
|
|
142
|
-
function ensureIncidentReporter() {
|
|
143
|
-
if (incidentReporter) return incidentReporter;
|
|
144
|
-
const cfg = getConfig();
|
|
145
|
-
const token = cfg ? cfg.incidentGitHubToken : '';
|
|
146
|
-
const baseUrl = cfg ? cfg.incidentGitHubBaseUrl : '';
|
|
147
|
-
if (!token || !baseUrl) return null;
|
|
148
|
-
incidentReporter = new IncidentReporter({ token, baseUrl, repo: 'goodandready/dsh-key-rotation', fetchImpl: globalThis.fetch });
|
|
149
|
-
return incidentReporter;
|
|
150
|
-
}
|
|
151
|
-
const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
|
|
124
|
+
let sandboxRunner = null;
|
|
152
125
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
153
126
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
154
|
-
let canaryProber = null;
|
|
155
127
|
function ensureSandboxRunner(ctx) {
|
|
156
128
|
if (sandboxRunner) return sandboxRunner;
|
|
157
129
|
// provider id or key ref -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
@@ -211,20 +183,11 @@ export const Config = Schema.object({
|
|
|
211
183
|
maxCooldownMs: Schema.number(),
|
|
212
184
|
notifyWebhook: Schema.string().default(''),
|
|
213
185
|
notifyThreshold: Schema.number().default(3),
|
|
214
|
-
backupDir: Schema.string().default(''),
|
|
215
|
-
backupIntervalMs: Schema.number().default(86400000),
|
|
216
|
-
backupKeep: Schema.number().default(7),
|
|
217
|
-
rotationScheduleDays: Schema.number().default(0),
|
|
218
186
|
selfHealCooldown: Schema.boolean().default(true),
|
|
219
187
|
selfHealIdleMs: Schema.number().default(3600000),
|
|
220
188
|
latencyEnabled: Schema.boolean().default(true),
|
|
221
189
|
latencyWindow: Schema.number().default(200),
|
|
222
|
-
incidentGitHubToken: Schema.string().default(''),
|
|
223
|
-
incidentGitHubBaseUrl: Schema.string().default(''),
|
|
224
|
-
incidentThreshold: Schema.number().default(5),
|
|
225
190
|
concurrencyLimit: Schema.number().default(0),
|
|
226
|
-
canaryProbingEnabled: Schema.boolean().default(false),
|
|
227
|
-
canaryIntervalMs: Schema.number().default(30000),
|
|
228
191
|
cascade: Schema.array(Schema.object({
|
|
229
192
|
provider: Schema.string().required(),
|
|
230
193
|
model: Schema.string(),
|
|
@@ -235,7 +198,7 @@ export const Config = Schema.object({
|
|
|
235
198
|
}),
|
|
236
199
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
237
200
|
rpmLimit: Schema.number().default(0),
|
|
238
|
-
webhookActionToken: Schema.string().default(''),
|
|
201
|
+
webhookActionToken: Schema.string().role('secret').default(''),
|
|
239
202
|
expiryWarnDays: Schema.number().default(7),
|
|
240
203
|
switchNotify: Schema.boolean().default(false),
|
|
241
204
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
@@ -391,7 +354,6 @@ async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
391
354
|
// fields that legitimately hold tokens are masked before scanning.
|
|
392
355
|
try {
|
|
393
356
|
const masked = structuredClone(section);
|
|
394
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
395
357
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
396
358
|
// notifyWebhook legitimately carries bot tokens inside URLs
|
|
397
359
|
// (api.telegram.org/bot<token>/...) - scan it for nothing.
|
|
@@ -447,39 +409,6 @@ export function apply(ctx, config = {}) {
|
|
|
447
409
|
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
448
410
|
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
449
411
|
// fresh config on every tick. Naive but correct: 60s cadence is cheap.
|
|
450
|
-
// #196: canary probing before key release from cooldown.
|
|
451
|
-
// Every canaryIntervalMs, probe refs that are in cooldown and close to expiry.
|
|
452
|
-
// Canary prober lifecycle effect
|
|
453
|
-
ctx.effect(() => {
|
|
454
|
-
const cfg = getConfig();
|
|
455
|
-
if (!cfg || !cfg.canaryProbingEnabled) return () => {};
|
|
456
|
-
const timer = setInterval(() => {
|
|
457
|
-
try {
|
|
458
|
-
const c = getConfig();
|
|
459
|
-
if (!c || !c.canaryProbingEnabled) return;
|
|
460
|
-
const runner = ensureSandboxRunner(ctx);
|
|
461
|
-
if (!runner) return;
|
|
462
|
-
if (!canaryProber) {
|
|
463
|
-
canaryProber = new CanaryProber({ sandboxRunner: runner, intervalMs: c.canaryIntervalMs });
|
|
464
|
-
}
|
|
465
|
-
const providers = Array.isArray(c.providers) ? c.providers : [];
|
|
466
|
-
for (const p of providers) {
|
|
467
|
-
const pool = buildRuntime().providerToPool.get(p.provider);
|
|
468
|
-
if (!pool) continue;
|
|
469
|
-
for (const ref of pool.refs) {
|
|
470
|
-
const until = pool.state.failedUntil.get(ref) ?? 0;
|
|
471
|
-
const now = Date.now();
|
|
472
|
-
if (until > now && until - now < (c.canaryIntervalMs ?? 30000)) {
|
|
473
|
-
canaryProber.probe(ref, ref);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} catch (_) { /* ponytail: never crash the timer */ }
|
|
478
|
-
}, cfg.canaryIntervalMs ?? 30000);
|
|
479
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
480
|
-
return () => clearInterval(timer);
|
|
481
|
-
}, 'dsh-key-rotation: canary prober');
|
|
482
|
-
|
|
483
412
|
// Self-healing idle cooldowns lifecycle effect
|
|
484
413
|
ctx.effect(() => {
|
|
485
414
|
const cfg = getConfig();
|
|
@@ -500,96 +429,9 @@ export function apply(ctx, config = {}) {
|
|
|
500
429
|
return () => clearInterval(timer);
|
|
501
430
|
}, 'dsh-key-rotation: self-healing idle');
|
|
502
431
|
|
|
503
|
-
//
|
|
504
|
-
const DASH_HTML = '';
|
|
505
|
-
// ── key-pool state, persisted across config reloads ──
|
|
432
|
+
// ── key-pool state, persisted across config reloads ──
|
|
506
433
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
507
434
|
const poolState = new Map();
|
|
508
|
-
// Periodic backup of pools config
|
|
509
|
-
ctx.effect(() => {
|
|
510
|
-
const { backupDir, backupIntervalMs, backupKeep } = buildRuntime();
|
|
511
|
-
if (!backupDir) return;
|
|
512
|
-
const id = setInterval(() => {
|
|
513
|
-
try {
|
|
514
|
-
const fs = require('node:fs');
|
|
515
|
-
const path = require('node:path');
|
|
516
|
-
const dir = backupDir;
|
|
517
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
518
|
-
const now = new Date();
|
|
519
|
-
const dateStr = now.toISOString().slice(0,10).replace(/-/g,'');
|
|
520
|
-
const file = path.join(dir, 'pools-' + dateStr + '.json');
|
|
521
|
-
const data = JSON.stringify({ backup: now.toISOString(), providers: getConfig()?.providers ?? [] }, null, 2);
|
|
522
|
-
fs.writeFileSync(file, data, 'utf8');
|
|
523
|
-
// prune old backups
|
|
524
|
-
const keep = backupKeep || 7;
|
|
525
|
-
const files = fs.readdirSync(dir).filter((f) => f.startsWith('pools-') && f.endsWith('.json')).sort();
|
|
526
|
-
while (files.length > keep) {
|
|
527
|
-
const old = files.shift();
|
|
528
|
-
fs.unlinkSync(path.join(dir, old));
|
|
529
|
-
}
|
|
530
|
-
} catch (e) {
|
|
531
|
-
console.warn('[dsh-key-rotation] backup failed:', String(e?.message ?? e));
|
|
532
|
-
}
|
|
533
|
-
}, backupIntervalMs || 86400000);
|
|
534
|
-
return () => clearInterval(id);
|
|
535
|
-
}, 'dsh-key-rotation: backup pools');
|
|
536
|
-
// Periodic save of usage/cost stats to file
|
|
537
|
-
ctx.effect(() => {
|
|
538
|
-
const { backupDir } = buildRuntime();
|
|
539
|
-
if (!backupDir) return;
|
|
540
|
-
try {
|
|
541
|
-
const fs = require('node:fs');
|
|
542
|
-
const path = require('node:path');
|
|
543
|
-
const statsFile = path.join(backupDir, 'stats.json');
|
|
544
|
-
// Load existing stats at startup
|
|
545
|
-
try {
|
|
546
|
-
if (fs.existsSync(statsFile)) {
|
|
547
|
-
const saved = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
548
|
-
for (const st of poolState.values()) {
|
|
549
|
-
if (saved.usageCounts && st.usageCounts) { for (const [k, v] of Object.entries(saved.usageCounts)) st.usageCounts.set(k, (st.usageCounts.get(k) ?? 0) + v); }
|
|
550
|
-
if (saved.costPerKey && st.costPerKey) { for (const [k, v] of Object.entries(saved.costPerKey)) st.costPerKey.set(k, (st.costPerKey.get(k) ?? 0) + v); }
|
|
551
|
-
if (saved.lastUsedAt && st.lastUsedAt) { for (const [k, v] of Object.entries(saved.lastUsedAt)) { if (!st.lastUsedAt.has(k) || v > st.lastUsedAt.get(k)) st.lastUsedAt.set(k, v); } }
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
} catch {}
|
|
555
|
-
// Periodic save
|
|
556
|
-
const id = setInterval(() => {
|
|
557
|
-
try {
|
|
558
|
-
const usageCounts = {}; const costPerKey = {}; const lastUsedAt = {};
|
|
559
|
-
for (const [base, st] of poolState) {
|
|
560
|
-
if (st.usageCounts) for (const [k, v] of st.usageCounts) usageCounts[k] = v;
|
|
561
|
-
if (st.costPerKey) for (const [k, v] of st.costPerKey) costPerKey[k] = v;
|
|
562
|
-
if (st.lastUsedAt) for (const [k, v] of st.lastUsedAt) lastUsedAt[k] = v;
|
|
563
|
-
}
|
|
564
|
-
fs.writeFileSync(statsFile, JSON.stringify({ t: Date.now(), usageCounts, costPerKey, lastUsedAt }), 'utf8');
|
|
565
|
-
} catch {}
|
|
566
|
-
}, 60000);
|
|
567
|
-
return () => clearInterval(id);
|
|
568
|
-
} catch { return () => {}; }
|
|
569
|
-
}, 'dsh-key-rotation: persist stats');
|
|
570
|
-
// Rotation schedule: shift pointer every N days
|
|
571
|
-
ctx.effect(() => {
|
|
572
|
-
const { rotationScheduleDays } = buildRuntime();
|
|
573
|
-
if (!rotationScheduleDays || rotationScheduleDays <= 0) return;
|
|
574
|
-
const intervalMs = Math.min(rotationScheduleDays * 86400000, 2147483647);
|
|
575
|
-
const id = setInterval(() => {
|
|
576
|
-
try {
|
|
577
|
-
const rt = buildRuntime();
|
|
578
|
-
let shifted = 0;
|
|
579
|
-
for (const pool of rt.poolByRef.values()) {
|
|
580
|
-
if (pool.refs.length < 2) continue;
|
|
581
|
-
const oldPtr = pool.state.pointer ?? 0;
|
|
582
|
-
pool.state.pointer = (oldPtr + 1) % pool.refs.length;
|
|
583
|
-
shifted++;
|
|
584
|
-
console.warn(`[dsh-key-rotation] ${pool.base}: scheduled rotation -> ${pool.refs[pool.state.pointer]} (day ${rotationScheduleDays})`);
|
|
585
|
-
}
|
|
586
|
-
if (shifted) console.warn(`[dsh-key-rotation] schedule: rotated ${shifted} pools`);
|
|
587
|
-
} catch (e) {
|
|
588
|
-
console.warn('[dsh-key-rotation] schedule error:', String(e?.message ?? e));
|
|
589
|
-
}
|
|
590
|
-
}, intervalMs);
|
|
591
|
-
return () => clearInterval(id);
|
|
592
|
-
}, 'dsh-key-rotation: rotation schedule');
|
|
593
435
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
594
436
|
ctx.effect(() => {
|
|
595
437
|
const id = setInterval(() => {
|
|
@@ -719,27 +561,34 @@ export function apply(ctx, config = {}) {
|
|
|
719
561
|
}, 'dsh-key-rotation: sweep expired cooldowns');
|
|
720
562
|
|
|
721
563
|
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
564
|
+
let cachedRuntime = null;
|
|
565
|
+
let lastConfigRef = null;
|
|
566
|
+
let lastProfilesRef = null;
|
|
567
|
+
|
|
722
568
|
getRuntime = buildRuntime;
|
|
723
569
|
function buildRuntime() {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
570
|
+
const rawConfig = getConfig() ?? {};
|
|
571
|
+
let currentProfiles = null;
|
|
572
|
+
try {
|
|
573
|
+
currentProfiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
|
|
574
|
+
} catch {
|
|
575
|
+
/* settings not mounted yet — empty mapping */
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (cachedRuntime && lastConfigRef === rawConfig && lastProfilesRef === currentProfiles) {
|
|
579
|
+
return cachedRuntime;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const cfg = Config(rawConfig) ?? {};
|
|
727
583
|
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
728
584
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
729
585
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
730
586
|
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
731
587
|
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
732
|
-
const backupDir = cfg.backupDir ?? '';
|
|
733
|
-
const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
|
|
734
|
-
const backupKeep = cfg.backupKeep ?? 7;
|
|
735
|
-
const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
|
|
736
588
|
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
737
589
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
738
590
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
739
|
-
const incidentThreshold = cfg.incidentThreshold ?? 5;
|
|
740
591
|
const concurrencyLimit = cfg.concurrencyLimit ?? 0;
|
|
741
|
-
const canaryProbingEnabled = cfg.canaryProbingEnabled ?? false;
|
|
742
|
-
const canaryIntervalMs = cfg.canaryIntervalMs ?? 30000;
|
|
743
592
|
const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
|
|
744
593
|
const quotaResetWindow = cfg.quotaResetWindow || null;
|
|
745
594
|
|
|
@@ -856,19 +705,23 @@ export function apply(ctx, config = {}) {
|
|
|
856
705
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
857
706
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
858
707
|
}
|
|
859
|
-
|
|
708
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
709
|
+
lastConfigRef = rawConfig;
|
|
710
|
+
lastProfilesRef = currentProfiles;
|
|
711
|
+
return cachedRuntime;
|
|
860
712
|
}
|
|
861
713
|
|
|
862
714
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
863
715
|
// Round-robin over the pool, skipping keys in cooldown; the request's
|
|
864
716
|
// provider identity never changes, so pi-ai replay state stays consistent.
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
717
|
+
ctx.effect(() => {
|
|
718
|
+
const credentials = ctx.get('credentials');
|
|
719
|
+
if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
|
|
720
|
+
const original = credentials.resolve.bind(credentials);
|
|
721
|
+
// Kept for the status route: it must ask about one exact ref instead of
|
|
722
|
+
// being rotated to a different key by the patch below.
|
|
723
|
+
credentials.__dshKeyRotationOriginalResolve = original;
|
|
724
|
+
credentials.resolve = async (ref) => {
|
|
872
725
|
const { poolByRef } = buildRuntime();
|
|
873
726
|
const pool = poolByRef.get(ref);
|
|
874
727
|
if (!pool) return original(ref);
|
|
@@ -903,9 +756,10 @@ export function apply(ctx, config = {}) {
|
|
|
903
756
|
continue;
|
|
904
757
|
}
|
|
905
758
|
}
|
|
759
|
+
// Advance pointer immediately so concurrent requests round-robin across distinct healthy keys
|
|
760
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
906
761
|
let hit = await original(candidate);
|
|
907
762
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
908
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
909
763
|
pool.state.lastUsed = candidate;
|
|
910
764
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
911
765
|
pool.state.failedUntil.delete(candidate);
|
|
@@ -925,7 +779,6 @@ export function apply(ctx, config = {}) {
|
|
|
925
779
|
// fallback: env var (transient, not persisted)
|
|
926
780
|
const envVal = envValue(candidate);
|
|
927
781
|
if (envVal !== undefined) {
|
|
928
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
929
782
|
pool.state.lastUsed = candidate;
|
|
930
783
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
931
784
|
pool.state.failedUntil.delete(candidate);
|
|
@@ -944,9 +797,17 @@ export function apply(ctx, config = {}) {
|
|
|
944
797
|
}
|
|
945
798
|
}
|
|
946
799
|
return original(ref); // everything cooled/missing — surface the base value
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
800
|
+
};
|
|
801
|
+
credentials.__dshKeyRotationPatched = true;
|
|
802
|
+
return () => {
|
|
803
|
+
if (credentials.__dshKeyRotationPatched) {
|
|
804
|
+
credentials.resolve = original;
|
|
805
|
+
delete credentials.__dshKeyRotationPatched;
|
|
806
|
+
delete credentials.__dshKeyRotationOriginalResolve;
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}, 'dsh-key-rotation: patch credentials.resolve');
|
|
950
811
|
|
|
951
812
|
const finishError = (code, message) => ({
|
|
952
813
|
type: 'finish',
|
|
@@ -1028,8 +889,7 @@ export function apply(ctx, config = {}) {
|
|
|
1028
889
|
const code = failure?.code;
|
|
1029
890
|
const message = failure?.message ?? '';
|
|
1030
891
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1031
|
-
const switchable = !yielded && kind === 'error' &&
|
|
1032
|
-
(effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
892
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
1033
893
|
if (switchable) {
|
|
1034
894
|
if (pool.state.lastUsed) {
|
|
1035
895
|
const _retry = parseRetryAfter(message);
|
|
@@ -1158,7 +1018,8 @@ export function apply(ctx, config = {}) {
|
|
|
1158
1018
|
}
|
|
1159
1019
|
}
|
|
1160
1020
|
|
|
1161
|
-
|
|
1021
|
+
const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
|
|
1022
|
+
yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
|
|
1162
1023
|
})();
|
|
1163
1024
|
}
|
|
1164
1025
|
|
|
@@ -1328,7 +1189,6 @@ export function apply(ctx, config = {}) {
|
|
|
1328
1189
|
const exportable = { ...value };
|
|
1329
1190
|
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1330
1191
|
exportable.webhookActionToken = '';
|
|
1331
|
-
if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
|
|
1332
1192
|
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1333
1193
|
return;
|
|
1334
1194
|
}
|
|
@@ -1340,7 +1200,6 @@ export function apply(ctx, config = {}) {
|
|
|
1340
1200
|
// #200 leak guard applies to imported content too
|
|
1341
1201
|
try {
|
|
1342
1202
|
const masked = structuredClone(snap);
|
|
1343
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
1344
1203
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1345
1204
|
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1346
1205
|
const findings = findSecrets(JSON.stringify(masked));
|
|
@@ -1354,7 +1213,6 @@ export function apply(ctx, config = {}) {
|
|
|
1354
1213
|
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1355
1214
|
const merged = { ...cur, ...snap };
|
|
1356
1215
|
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1357
|
-
if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
|
|
1358
1216
|
try {
|
|
1359
1217
|
await settings.replace(NS, merged, desc.revision);
|
|
1360
1218
|
const after = descriptorOf(ctx, NS);
|
|
@@ -1600,61 +1458,7 @@ export function apply(ctx, config = {}) {
|
|
|
1600
1458
|
},
|
|
1601
1459
|
}), 'dsh-key-rotation: sandbox cache');
|
|
1602
1460
|
|
|
1603
|
-
// Auto-incident reset (#8).
|
|
1604
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1605
|
-
kind: 'exact',
|
|
1606
|
-
path: INCIDENT_RESET_PATH,
|
|
1607
|
-
handler: (req, res) => {
|
|
1608
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1609
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1610
|
-
readJson(req).then((body) => {
|
|
1611
|
-
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1612
|
-
if (provider) incidentReporter.resetCooldown(provider);
|
|
1613
|
-
else incidentReporter.resetCooldown();
|
|
1614
|
-
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1615
|
-
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1616
|
-
},
|
|
1617
|
-
}), 'dsh-key-rotation: incident-reset');
|
|
1618
1461
|
|
|
1619
|
-
// #198: 1-click Health Matrix — parallel probe of all configured keys.
|
|
1620
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1621
|
-
kind: 'exact',
|
|
1622
|
-
path: TEST_MATRIX_PATH,
|
|
1623
|
-
handler: async (req, res) => {
|
|
1624
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1625
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: matrix is local-only' } }); return; }
|
|
1626
|
-
const cfg = getConfig();
|
|
1627
|
-
const runner = ensureSandboxRunner();
|
|
1628
|
-
if (!runner) { json(res, 500, { error: { code: 'no-runner', message: 'sandbox runner unavailable' } }); return; }
|
|
1629
|
-
const providers = Array.isArray(cfg?.providers) ? cfg.providers : [];
|
|
1630
|
-
const jobs = [];
|
|
1631
|
-
for (const p of providers) {
|
|
1632
|
-
for (const ref of (p.keys ?? [])) {
|
|
1633
|
-
if (typeof ref !== 'string' || !ref) continue;
|
|
1634
|
-
jobs.push((async () => {
|
|
1635
|
-
try {
|
|
1636
|
-
const probeResult = await runner.probeModels(ref, ref);
|
|
1637
|
-
return { provider: p.provider, ref, ok: probeResult.ok, code: probeResult.code, latencyMs: probeResult.latencyMs, modelsCount: probeResult.modelsCount ?? 0 };
|
|
1638
|
-
} catch (e) {
|
|
1639
|
-
return { provider: p.provider, ref, ok: false, code: 'error', latencyMs: 0, modelsCount: 0 };
|
|
1640
|
-
}
|
|
1641
|
-
})());
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
const results = await Promise.all(jobs);
|
|
1645
|
-
json(res, 200, { at: Date.now(), total: results.length, ok: results.filter(r => r.ok).length, results });
|
|
1646
|
-
},
|
|
1647
|
-
}), 'dsh-key-rotation: test-matrix');
|
|
1648
|
-
|
|
1649
|
-
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1650
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1651
|
-
kind: 'exact',
|
|
1652
|
-
path: WEBHOOK_TEST_PATH,
|
|
1653
|
-
handler: (req, res) => {
|
|
1654
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1655
|
-
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1656
|
-
},
|
|
1657
|
-
}), 'dsh-key-rotation: webhook-test');
|
|
1658
1462
|
|
|
1659
1463
|
// #199 webhook-action: interactive webhook buttons call back here.
|
|
1660
1464
|
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
@@ -1743,41 +1547,9 @@ export function apply(ctx, config = {}) {
|
|
|
1743
1547
|
},
|
|
1744
1548
|
}), 'dsh-key-rotation: webhook-action');
|
|
1745
1549
|
|
|
1746
|
-
// Shadow A/B sampling snapshot (#9).
|
|
1747
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1748
|
-
kind: 'exact',
|
|
1749
|
-
path: SHADOW_PATH,
|
|
1750
|
-
handler: (req, res) => {
|
|
1751
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1752
|
-
json(res, 200, shadowRouter.snapshot());
|
|
1753
|
-
},
|
|
1754
|
-
}), 'dsh-key-rotation: shadow');
|
|
1755
1550
|
|
|
1756
|
-
// Region tags + failover chain (#4).
|
|
1757
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1758
|
-
kind: 'exact',
|
|
1759
|
-
path: REGIONS_PATH,
|
|
1760
|
-
handler: (req, res) => {
|
|
1761
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1762
|
-
const body = regionMap.snapshot();
|
|
1763
|
-
// Add pickFallback hints per provider for inspection.
|
|
1764
|
-
const out = {};
|
|
1765
|
-
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1766
|
-
json(res, 200, out);
|
|
1767
|
-
},
|
|
1768
|
-
}), 'dsh-key-rotation: regions');
|
|
1769
|
-
|
|
1770
|
-
// Per-agent rate budget snapshot (#3).
|
|
1771
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1772
|
-
kind: 'exact',
|
|
1773
|
-
path: AGENT_BUDGET_PATH,
|
|
1774
|
-
handler: (req, res) => {
|
|
1775
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1776
|
-
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1777
|
-
},
|
|
1778
|
-
}), 'dsh-key-rotation: agent-budget');
|
|
1779
1551
|
|
|
1780
|
-
ctx.on('llm/stream', (options, next) => {
|
|
1552
|
+
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
1781
1553
|
if (options[MARKER]) return next();
|
|
1782
1554
|
if (rotationDisabled) return next(); // #199: disabled via webhook action
|
|
1783
1555
|
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
|
@@ -1786,13 +1558,13 @@ export function apply(ctx, config = {}) {
|
|
|
1786
1558
|
if (!pool) return next();
|
|
1787
1559
|
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
|
|
1788
1560
|
return rotate(options, pool);
|
|
1789
|
-
});
|
|
1561
|
+
}), 'dsh-key-rotation: llm/stream');
|
|
1790
1562
|
|
|
1791
1563
|
// Safety net for non-stream requests (agent/request-error waterfall).
|
|
1792
1564
|
// llm/stream covers streaming calls; sync calls (embeddings, batch) go
|
|
1793
1565
|
// through agent/request and surface errors here. If the error is
|
|
1794
1566
|
// switchable, mark the key and ask the agent loop to retry.
|
|
1795
|
-
ctx.on('agent/request-error', async (payload, next) => {
|
|
1567
|
+
ctx.effect(() => ctx.on('agent/request-error', async (payload, next) => {
|
|
1796
1568
|
const provider = payload?.provider ?? payload?.failure?.provider ?? '';
|
|
1797
1569
|
if (!provider) return next();
|
|
1798
1570
|
const { providerToPool, modelPoolByProvider, switchCodes } = buildRuntime();
|
|
@@ -1803,7 +1575,7 @@ export function apply(ctx, config = {}) {
|
|
|
1803
1575
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
1804
1576
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
1805
1577
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1806
|
-
const switchable =
|
|
1578
|
+
const switchable = isSwitchableError(payload, effectiveSwitchCodes);
|
|
1807
1579
|
if (!switchable) return next();
|
|
1808
1580
|
const ref = pool.state.lastUsed;
|
|
1809
1581
|
if (ref) {
|
|
@@ -1815,7 +1587,7 @@ export function apply(ctx, config = {}) {
|
|
|
1815
1587
|
console.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
|
|
1816
1588
|
}
|
|
1817
1589
|
return { kind: 'retry' };
|
|
1818
|
-
});
|
|
1590
|
+
}), 'dsh-key-rotation: agent/request-error');
|
|
1819
1591
|
|
|
1820
1592
|
ctx.inject(['settings'], (sctx) => {
|
|
1821
1593
|
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
@@ -1826,18 +1598,15 @@ export function apply(ctx, config = {}) {
|
|
|
1826
1598
|
});
|
|
1827
1599
|
}
|
|
1828
1600
|
|
|
1829
|
-
// Notify on exhaustion: webhook
|
|
1601
|
+
// Notify on exhaustion: webhook notification.
|
|
1830
1602
|
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
1831
1603
|
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
1832
|
-
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
1604
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
|
|
1833
1605
|
if (!runtime || !pool) return;
|
|
1834
1606
|
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
1835
1607
|
if (count <= 0) return;
|
|
1836
1608
|
try {
|
|
1837
1609
|
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
1838
|
-
// #199: interactive payload when an action token is configured - the
|
|
1839
|
-
// platform formatter (webhook.js) turns `actions` into buttons whose
|
|
1840
|
-
// callback carries the token back to /dsh-key-rotation/webhook-action.
|
|
1841
1610
|
const token = runtime.webhookActionToken ?? '';
|
|
1842
1611
|
const payload = {
|
|
1843
1612
|
title: `Key pool exhausted: ${options.provider}`,
|
|
@@ -1854,10 +1623,6 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
|
1854
1623
|
};
|
|
1855
1624
|
hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
1856
1625
|
}
|
|
1857
|
-
if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
|
|
1858
|
-
const reporter = hooks.ensureIncidentReporter();
|
|
1859
|
-
if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
|
|
1860
|
-
}
|
|
1861
1626
|
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1862
1627
|
}
|
|
1863
1628
|
|