@goodandready/dsh-key-rotation 0.7.35 → 0.7.37
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 -0
- package/README.ru.md +16 -0
- package/README.zh.md +16 -0
- package/lib/client.js +27 -6
- package/lib/index.js +136 -334
- package/lib/pool.js +110 -1
- package/package.json +1 -1
- 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
|
@@ -32,12 +32,13 @@
|
|
|
32
32
|
// cooldownMs: number key cooldown after a switchable failure
|
|
33
33
|
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
35
36
|
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';
|
|
37
|
+
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
38
|
|
|
38
39
|
export const name = 'dsh-key-rotation';
|
|
39
40
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
40
|
-
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
|
|
41
|
+
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage };
|
|
41
42
|
|
|
42
43
|
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
43
44
|
const NS = 'dsh-key-rotation';
|
|
@@ -52,28 +53,18 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
52
53
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
53
54
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
54
55
|
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
56
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
62
57
|
import { healIdleCooldowns } from './heal.js';
|
|
63
58
|
import { LatencyHistogram } from './histogram.js';
|
|
64
59
|
import { pickCascadeFallback } from './cascade.js';
|
|
65
60
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
66
61
|
import { nextQuotaReset } from './quota-window.js';
|
|
67
|
-
import { CanaryProber } from './canary.js';
|
|
68
62
|
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
63
|
import { WebhookSender } from './webhook.js';
|
|
74
64
|
import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
|
|
75
|
-
import {
|
|
76
|
-
|
|
65
|
+
import { usageRows, usageCsv, compactUsage } from './usage-report.js';
|
|
66
|
+
|
|
67
|
+
const dispatchStorage = new AsyncLocalStorage();
|
|
77
68
|
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
78
69
|
|
|
79
70
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
@@ -130,28 +121,12 @@ let lastTestCacheRunnerCtx = null;
|
|
|
130
121
|
const lastTestCache = new LastTestCache();
|
|
131
122
|
const latencyHistogram = new LatencyHistogram();
|
|
132
123
|
const quotaStore = new QuotaStore();
|
|
133
|
-
const agentBudget = new AgentBudget();
|
|
134
|
-
const regionMap = new RegionMap();
|
|
135
124
|
// Global config accessor safe against early initialization
|
|
136
125
|
let getConfig = () => null;
|
|
137
126
|
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;
|
|
127
|
+
let sandboxRunner = null;
|
|
152
128
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
153
129
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
154
|
-
let canaryProber = null;
|
|
155
130
|
function ensureSandboxRunner(ctx) {
|
|
156
131
|
if (sandboxRunner) return sandboxRunner;
|
|
157
132
|
// provider id or key ref -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
@@ -211,20 +186,11 @@ export const Config = Schema.object({
|
|
|
211
186
|
maxCooldownMs: Schema.number(),
|
|
212
187
|
notifyWebhook: Schema.string().default(''),
|
|
213
188
|
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
189
|
selfHealCooldown: Schema.boolean().default(true),
|
|
219
190
|
selfHealIdleMs: Schema.number().default(3600000),
|
|
220
191
|
latencyEnabled: Schema.boolean().default(true),
|
|
221
192
|
latencyWindow: Schema.number().default(200),
|
|
222
|
-
incidentGitHubToken: Schema.string().role('secret').default(''),
|
|
223
|
-
incidentGitHubBaseUrl: Schema.string().default(''),
|
|
224
|
-
incidentThreshold: Schema.number().default(5),
|
|
225
193
|
concurrencyLimit: Schema.number().default(0),
|
|
226
|
-
canaryProbingEnabled: Schema.boolean().default(false),
|
|
227
|
-
canaryIntervalMs: Schema.number().default(30000),
|
|
228
194
|
cascade: Schema.array(Schema.object({
|
|
229
195
|
provider: Schema.string().required(),
|
|
230
196
|
model: Schema.string(),
|
|
@@ -391,7 +357,6 @@ async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
391
357
|
// fields that legitimately hold tokens are masked before scanning.
|
|
392
358
|
try {
|
|
393
359
|
const masked = structuredClone(section);
|
|
394
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
395
360
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
396
361
|
// notifyWebhook legitimately carries bot tokens inside URLs
|
|
397
362
|
// (api.telegram.org/bot<token>/...) - scan it for nothing.
|
|
@@ -447,39 +412,6 @@ export function apply(ctx, config = {}) {
|
|
|
447
412
|
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
448
413
|
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
449
414
|
// 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
415
|
// Self-healing idle cooldowns lifecycle effect
|
|
484
416
|
ctx.effect(() => {
|
|
485
417
|
const cfg = getConfig();
|
|
@@ -500,96 +432,9 @@ export function apply(ctx, config = {}) {
|
|
|
500
432
|
return () => clearInterval(timer);
|
|
501
433
|
}, 'dsh-key-rotation: self-healing idle');
|
|
502
434
|
|
|
503
|
-
//
|
|
504
|
-
const DASH_HTML = '';
|
|
505
|
-
// ── key-pool state, persisted across config reloads ──
|
|
435
|
+
// ── key-pool state, persisted across config reloads ──
|
|
506
436
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
507
437
|
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
438
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
594
439
|
ctx.effect(() => {
|
|
595
440
|
const id = setInterval(() => {
|
|
@@ -607,6 +452,9 @@ export function apply(ctx, config = {}) {
|
|
|
607
452
|
}
|
|
608
453
|
const n = sweepExpired(poolState, now);
|
|
609
454
|
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
455
|
+
for (const pool of buildRuntime().poolByRef.values()) {
|
|
456
|
+
compactUsage(pool, 30, now);
|
|
457
|
+
}
|
|
610
458
|
// #207 expiry pre-warning + #208 cost budget - piggybacked on this timer,
|
|
611
459
|
// deduped to one notification per key/window per day (shouldNotifyDaily).
|
|
612
460
|
try {
|
|
@@ -719,27 +567,34 @@ export function apply(ctx, config = {}) {
|
|
|
719
567
|
}, 'dsh-key-rotation: sweep expired cooldowns');
|
|
720
568
|
|
|
721
569
|
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
570
|
+
let cachedRuntime = null;
|
|
571
|
+
let lastConfigRef = null;
|
|
572
|
+
let lastProfilesRef = null;
|
|
573
|
+
|
|
722
574
|
getRuntime = buildRuntime;
|
|
723
575
|
function buildRuntime() {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
576
|
+
const rawConfig = getConfig() ?? {};
|
|
577
|
+
let currentProfiles = null;
|
|
578
|
+
try {
|
|
579
|
+
currentProfiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
|
|
580
|
+
} catch {
|
|
581
|
+
/* settings not mounted yet — empty mapping */
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (cachedRuntime && lastConfigRef === rawConfig && lastProfilesRef === currentProfiles) {
|
|
585
|
+
return cachedRuntime;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const cfg = Config(rawConfig) ?? {};
|
|
727
589
|
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
728
590
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
729
591
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
730
592
|
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
731
593
|
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
594
|
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
737
595
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
738
596
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
739
|
-
const incidentThreshold = cfg.incidentThreshold ?? 5;
|
|
740
597
|
const concurrencyLimit = cfg.concurrencyLimit ?? 0;
|
|
741
|
-
const canaryProbingEnabled = cfg.canaryProbingEnabled ?? false;
|
|
742
|
-
const canaryIntervalMs = cfg.canaryIntervalMs ?? 30000;
|
|
743
598
|
const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
|
|
744
599
|
const quotaResetWindow = cfg.quotaResetWindow || null;
|
|
745
600
|
|
|
@@ -856,7 +711,10 @@ export function apply(ctx, config = {}) {
|
|
|
856
711
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
857
712
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
858
713
|
}
|
|
859
|
-
|
|
714
|
+
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 };
|
|
715
|
+
lastConfigRef = rawConfig;
|
|
716
|
+
lastProfilesRef = currentProfiles;
|
|
717
|
+
return cachedRuntime;
|
|
860
718
|
}
|
|
861
719
|
|
|
862
720
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -904,10 +762,13 @@ export function apply(ctx, config = {}) {
|
|
|
904
762
|
continue;
|
|
905
763
|
}
|
|
906
764
|
}
|
|
765
|
+
// Advance pointer immediately so concurrent requests round-robin across distinct healthy keys
|
|
766
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
907
767
|
let hit = await original(candidate);
|
|
908
768
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
909
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
910
769
|
pool.state.lastUsed = candidate;
|
|
770
|
+
const store = dispatchStorage.getStore();
|
|
771
|
+
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
911
772
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
912
773
|
pool.state.failedUntil.delete(candidate);
|
|
913
774
|
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
@@ -926,8 +787,9 @@ export function apply(ctx, config = {}) {
|
|
|
926
787
|
// fallback: env var (transient, not persisted)
|
|
927
788
|
const envVal = envValue(candidate);
|
|
928
789
|
if (envVal !== undefined) {
|
|
929
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
930
790
|
pool.state.lastUsed = candidate;
|
|
791
|
+
const store = dispatchStorage.getStore();
|
|
792
|
+
if (store && store.pool === pool) store.pickedRef = candidate;
|
|
931
793
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
932
794
|
pool.state.failedUntil.delete(candidate);
|
|
933
795
|
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
@@ -965,13 +827,14 @@ export function apply(ctx, config = {}) {
|
|
|
965
827
|
// Latency recording (#6): record successful llm/stream latency per ref.
|
|
966
828
|
// ponytail: only the true success path (finish-chunk). Failures are not recorded.
|
|
967
829
|
let _rotateStartMs = Date.now();
|
|
968
|
-
function recordLatency(pool) {
|
|
830
|
+
function recordLatency(pool, reqStore) {
|
|
969
831
|
try {
|
|
970
832
|
const cfg = getConfig();
|
|
971
833
|
if (!cfg || cfg.latencyEnabled === false) return;
|
|
972
|
-
const ref =
|
|
834
|
+
const ref = reqStore?.pickedRef ?? pool?.state?.lastUsed;
|
|
973
835
|
if (!ref) return;
|
|
974
|
-
const
|
|
836
|
+
const startMs = reqStore?.startMs ?? _rotateStartMs;
|
|
837
|
+
const elapsed = Date.now() - startMs;
|
|
975
838
|
if (!Number.isFinite(elapsed) || elapsed < 0) return;
|
|
976
839
|
latencyHistogram.record(ref, elapsed);
|
|
977
840
|
} catch (_) { /* ponytail: never crash */ }
|
|
@@ -984,12 +847,14 @@ export function apply(ctx, config = {}) {
|
|
|
984
847
|
return (async function* () {
|
|
985
848
|
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
986
849
|
let lastFailure = null;
|
|
987
|
-
|
|
850
|
+
const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
|
|
851
|
+
_rotateStartMs = reqStore.startMs;
|
|
988
852
|
|
|
989
853
|
const runtime0 = buildRuntime();
|
|
854
|
+
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
990
855
|
if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
991
856
|
// #193: prefer least-loaded key within limit
|
|
992
|
-
const available =
|
|
857
|
+
const available = attemptList.filter((r) => {
|
|
993
858
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
994
859
|
if (fu > Date.now()) return false;
|
|
995
860
|
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
@@ -997,30 +862,54 @@ export function apply(ctx, config = {}) {
|
|
|
997
862
|
return true;
|
|
998
863
|
});
|
|
999
864
|
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
1000
|
-
if (preferred &&
|
|
1001
|
-
|
|
1002
|
-
const list = (pool.weightedRefs ?? pool.refs).slice();
|
|
865
|
+
if (preferred && attemptList[0] !== preferred) {
|
|
866
|
+
const list = attemptList.slice();
|
|
1003
867
|
const i = list.indexOf(preferred);
|
|
1004
868
|
if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
|
|
1005
|
-
|
|
869
|
+
attemptList = list;
|
|
1006
870
|
}
|
|
1007
871
|
}
|
|
1008
|
-
|
|
872
|
+
|
|
873
|
+
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
874
|
+
if (!targetRef) return;
|
|
875
|
+
const _retry = parseRetryAfter(errMsg);
|
|
876
|
+
const _base = pool.cooldownMs ?? cooldownMs;
|
|
877
|
+
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
878
|
+
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
879
|
+
const _b = recordFailure(pool, targetRef, Date.now(), _effBase, _max);
|
|
880
|
+
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
881
|
+
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
882
|
+
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
883
|
+
const _cStr = String(errCode ?? '');
|
|
884
|
+
if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
|
|
885
|
+
const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
|
|
886
|
+
pool.state.authFailCounts.set(targetRef, _c2);
|
|
887
|
+
if (_c2 >= 3) {
|
|
888
|
+
pool.state.brokenUntil.set(targetRef, Date.now() + 86400000 * 30);
|
|
889
|
+
pool.state.failedUntil.set(targetRef, Date.now() + 86400000 * 30);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
pool.state.authFailCounts.delete(targetRef);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
1009
897
|
let yielded = false;
|
|
1010
898
|
let switching = false;
|
|
1011
899
|
let inner;
|
|
1012
900
|
try {
|
|
1013
901
|
// mark the internal dispatch so the interceptor does not re-rotate
|
|
1014
|
-
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
902
|
+
inner = dispatchStorage.run(reqStore, () => ctx.llm.stream({ ...options, [MARKER]: true }));
|
|
1015
903
|
} catch (e) {
|
|
1016
|
-
|
|
904
|
+
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
905
|
+
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
1017
906
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
1018
907
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
1019
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(
|
|
908
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
1020
909
|
continue;
|
|
1021
910
|
}
|
|
1022
911
|
|
|
1023
|
-
const _pickedRef = pool.state.lastUsed;
|
|
912
|
+
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
1024
913
|
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
|
|
1025
914
|
try {
|
|
1026
915
|
for await (const chunk of inner) {
|
|
@@ -1037,41 +926,20 @@ export function apply(ctx, config = {}) {
|
|
|
1037
926
|
const code = failure?.code;
|
|
1038
927
|
const message = failure?.message ?? '';
|
|
1039
928
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1040
|
-
const switchable = !yielded && kind === 'error' &&
|
|
1041
|
-
|
|
929
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
930
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
1042
931
|
if (switchable) {
|
|
1043
|
-
|
|
1044
|
-
const _retry = parseRetryAfter(message);
|
|
1045
|
-
const _base = pool.cooldownMs ?? cooldownMs;
|
|
1046
|
-
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
1047
|
-
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
1048
|
-
const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max);
|
|
1049
|
-
pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b);
|
|
1050
|
-
// authFailCounts/brokenUntil: lazy-init if state was created by an older plugin version
|
|
1051
|
-
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
1052
|
-
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
1053
|
-
const _code2 = String(code ?? '');
|
|
1054
|
-
if (_code2 === 'AUTH' || /auth/i.test(message)) {
|
|
1055
|
-
const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1;
|
|
1056
|
-
pool.state.authFailCounts.set(pool.state.lastUsed, _c2);
|
|
1057
|
-
if (_c2 >= 3) {
|
|
1058
|
-
pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
|
|
1059
|
-
pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
|
|
1060
|
-
}
|
|
1061
|
-
} else {
|
|
1062
|
-
pool.state.authFailCounts.delete(pool.state.lastUsed);
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
932
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
1065
933
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1066
934
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
1067
935
|
pool.state.lastSwitchAt = Date.now();
|
|
1068
936
|
lastFailure = chunk;
|
|
1069
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(
|
|
937
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
1070
938
|
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
1071
|
-
if (buildRuntime().switchNotify &&
|
|
939
|
+
if (buildRuntime().switchNotify && activeRef) {
|
|
1072
940
|
notifySwitch(buildRuntime(), pool, {
|
|
1073
941
|
provider: options.provider,
|
|
1074
|
-
from:
|
|
942
|
+
from: activeRef,
|
|
1075
943
|
code: String(code ?? 'UNKNOWN'),
|
|
1076
944
|
at: pool.state.lastSwitchAt,
|
|
1077
945
|
});
|
|
@@ -1080,59 +948,78 @@ export function apply(ctx, config = {}) {
|
|
|
1080
948
|
break;
|
|
1081
949
|
}
|
|
1082
950
|
// cost tracking if provider returns usage.cost
|
|
1083
|
-
if (chunk.usage?.cost != null &&
|
|
951
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
1084
952
|
const c = Number(chunk.usage.cost);
|
|
1085
953
|
if (!isNaN(c)) {
|
|
1086
954
|
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
1087
|
-
pool.state.costPerKey.set(
|
|
955
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
1088
956
|
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
1089
957
|
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
1090
958
|
const cday = new Date().toISOString().slice(0, 10);
|
|
1091
|
-
const cMap = pool.state.costDays.get(
|
|
959
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
1092
960
|
cMap.set(cday, (cMap.get(cday) ?? 0) + c);
|
|
1093
|
-
pool.state.costDays.set(
|
|
961
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
1094
962
|
}
|
|
1095
963
|
}
|
|
1096
964
|
// Usage by day (#119)
|
|
1097
|
-
if (
|
|
965
|
+
if (activeRef) {
|
|
1098
966
|
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
1099
967
|
const day = new Date().toISOString().slice(0, 10);
|
|
1100
|
-
const dayMap = pool.state.usageDays.get(
|
|
968
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
1101
969
|
dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
|
|
1102
|
-
pool.state.usageDays.set(
|
|
970
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
1103
971
|
}
|
|
1104
972
|
// Per-model request detail (#121)
|
|
1105
|
-
if (
|
|
973
|
+
if (activeRef && options.model) {
|
|
1106
974
|
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
1107
|
-
let byRef = pool.state.byModel.get(
|
|
1108
|
-
if (!byRef) { byRef = new Map(); pool.state.byModel.set(
|
|
975
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
976
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
1109
977
|
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
1110
978
|
}
|
|
1111
979
|
// Proactive rate-limit (#115): if response headers say this key is near
|
|
1112
980
|
// its quota, cool it down so the NEXT request starts on a different key.
|
|
1113
981
|
// We do NOT re-run this (already successful) request — that would double-send.
|
|
1114
982
|
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
1115
|
-
if (rate &&
|
|
983
|
+
if (rate && activeRef) {
|
|
1116
984
|
const { rateLimitThreshold } = buildRuntime();
|
|
1117
985
|
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
1118
986
|
const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
|
|
1119
|
-
recordFailure(pool,
|
|
1120
|
-
pushEvent(pool,
|
|
1121
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${
|
|
987
|
+
recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
|
|
988
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
989
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
1122
990
|
}
|
|
1123
991
|
}
|
|
1124
992
|
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
1125
|
-
if (rate &&
|
|
1126
|
-
quotaStore.set(
|
|
993
|
+
if (rate && activeRef && Number.isFinite(rate.remaining)) {
|
|
994
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
|
|
1127
995
|
}
|
|
1128
996
|
yield chunk;
|
|
1129
|
-
recordLatency(pool);
|
|
997
|
+
recordLatency(pool, reqStore);
|
|
1130
998
|
return;
|
|
1131
999
|
}
|
|
1132
1000
|
yield chunk;
|
|
1133
1001
|
}
|
|
1134
1002
|
} catch (e) {
|
|
1135
1003
|
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
1004
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1005
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
1006
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
1007
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1008
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1009
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
1010
|
+
pool.state.lastSwitchAt = Date.now();
|
|
1011
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1012
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
1013
|
+
if (buildRuntime().switchNotify && activeRef) {
|
|
1014
|
+
notifySwitch(buildRuntime(), pool, {
|
|
1015
|
+
provider: options.provider,
|
|
1016
|
+
from: activeRef,
|
|
1017
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
1018
|
+
at: pool.state.lastSwitchAt,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
continue; // Failover to next key!
|
|
1022
|
+
}
|
|
1136
1023
|
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1137
1024
|
return;
|
|
1138
1025
|
}
|
|
@@ -1167,7 +1054,8 @@ export function apply(ctx, config = {}) {
|
|
|
1167
1054
|
}
|
|
1168
1055
|
}
|
|
1169
1056
|
|
|
1170
|
-
|
|
1057
|
+
const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
|
|
1058
|
+
yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
|
|
1171
1059
|
})();
|
|
1172
1060
|
}
|
|
1173
1061
|
|
|
@@ -1337,7 +1225,6 @@ export function apply(ctx, config = {}) {
|
|
|
1337
1225
|
const exportable = { ...value };
|
|
1338
1226
|
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1339
1227
|
exportable.webhookActionToken = '';
|
|
1340
|
-
if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
|
|
1341
1228
|
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1342
1229
|
return;
|
|
1343
1230
|
}
|
|
@@ -1349,7 +1236,6 @@ export function apply(ctx, config = {}) {
|
|
|
1349
1236
|
// #200 leak guard applies to imported content too
|
|
1350
1237
|
try {
|
|
1351
1238
|
const masked = structuredClone(snap);
|
|
1352
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
1353
1239
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1354
1240
|
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1355
1241
|
const findings = findSecrets(JSON.stringify(masked));
|
|
@@ -1363,7 +1249,6 @@ export function apply(ctx, config = {}) {
|
|
|
1363
1249
|
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1364
1250
|
const merged = { ...cur, ...snap };
|
|
1365
1251
|
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1366
|
-
if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
|
|
1367
1252
|
try {
|
|
1368
1253
|
await settings.replace(NS, merged, desc.revision);
|
|
1369
1254
|
const after = descriptorOf(ctx, NS);
|
|
@@ -1586,6 +1471,16 @@ export function apply(ctx, config = {}) {
|
|
|
1586
1471
|
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
1587
1472
|
const cached = { ...result, at: Date.now() };
|
|
1588
1473
|
lastTestCache.set(ref, cached);
|
|
1474
|
+
if (cached.ok) {
|
|
1475
|
+
for (const st of poolState.values()) {
|
|
1476
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
1477
|
+
st.failedUntil?.delete(ref);
|
|
1478
|
+
st.failCounts?.delete(ref);
|
|
1479
|
+
st.authFailCounts?.delete(ref);
|
|
1480
|
+
st.brokenUntil?.delete(ref);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1589
1484
|
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
1590
1485
|
return;
|
|
1591
1486
|
}
|
|
@@ -1609,61 +1504,7 @@ export function apply(ctx, config = {}) {
|
|
|
1609
1504
|
},
|
|
1610
1505
|
}), 'dsh-key-rotation: sandbox cache');
|
|
1611
1506
|
|
|
1612
|
-
// Auto-incident reset (#8).
|
|
1613
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1614
|
-
kind: 'exact',
|
|
1615
|
-
path: INCIDENT_RESET_PATH,
|
|
1616
|
-
handler: (req, res) => {
|
|
1617
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1618
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1619
|
-
readJson(req).then((body) => {
|
|
1620
|
-
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1621
|
-
if (provider) incidentReporter.resetCooldown(provider);
|
|
1622
|
-
else incidentReporter.resetCooldown();
|
|
1623
|
-
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1624
|
-
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1625
|
-
},
|
|
1626
|
-
}), 'dsh-key-rotation: incident-reset');
|
|
1627
|
-
|
|
1628
|
-
// #198: 1-click Health Matrix — parallel probe of all configured keys.
|
|
1629
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1630
|
-
kind: 'exact',
|
|
1631
|
-
path: TEST_MATRIX_PATH,
|
|
1632
|
-
handler: async (req, res) => {
|
|
1633
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1634
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: matrix is local-only' } }); return; }
|
|
1635
|
-
const cfg = getConfig();
|
|
1636
|
-
const runner = ensureSandboxRunner();
|
|
1637
|
-
if (!runner) { json(res, 500, { error: { code: 'no-runner', message: 'sandbox runner unavailable' } }); return; }
|
|
1638
|
-
const providers = Array.isArray(cfg?.providers) ? cfg.providers : [];
|
|
1639
|
-
const jobs = [];
|
|
1640
|
-
for (const p of providers) {
|
|
1641
|
-
for (const ref of (p.keys ?? [])) {
|
|
1642
|
-
if (typeof ref !== 'string' || !ref) continue;
|
|
1643
|
-
jobs.push((async () => {
|
|
1644
|
-
try {
|
|
1645
|
-
const probeResult = await runner.probeModels(ref, ref);
|
|
1646
|
-
return { provider: p.provider, ref, ok: probeResult.ok, code: probeResult.code, latencyMs: probeResult.latencyMs, modelsCount: probeResult.modelsCount ?? 0 };
|
|
1647
|
-
} catch (e) {
|
|
1648
|
-
return { provider: p.provider, ref, ok: false, code: 'error', latencyMs: 0, modelsCount: 0 };
|
|
1649
|
-
}
|
|
1650
|
-
})());
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
const results = await Promise.all(jobs);
|
|
1654
|
-
json(res, 200, { at: Date.now(), total: results.length, ok: results.filter(r => r.ok).length, results });
|
|
1655
|
-
},
|
|
1656
|
-
}), 'dsh-key-rotation: test-matrix');
|
|
1657
1507
|
|
|
1658
|
-
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1659
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1660
|
-
kind: 'exact',
|
|
1661
|
-
path: WEBHOOK_TEST_PATH,
|
|
1662
|
-
handler: (req, res) => {
|
|
1663
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1664
|
-
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1665
|
-
},
|
|
1666
|
-
}), 'dsh-key-rotation: webhook-test');
|
|
1667
1508
|
|
|
1668
1509
|
// #199 webhook-action: interactive webhook buttons call back here.
|
|
1669
1510
|
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
@@ -1752,39 +1593,7 @@ export function apply(ctx, config = {}) {
|
|
|
1752
1593
|
},
|
|
1753
1594
|
}), 'dsh-key-rotation: webhook-action');
|
|
1754
1595
|
|
|
1755
|
-
// Shadow A/B sampling snapshot (#9).
|
|
1756
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1757
|
-
kind: 'exact',
|
|
1758
|
-
path: SHADOW_PATH,
|
|
1759
|
-
handler: (req, res) => {
|
|
1760
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1761
|
-
json(res, 200, shadowRouter.snapshot());
|
|
1762
|
-
},
|
|
1763
|
-
}), 'dsh-key-rotation: shadow');
|
|
1764
1596
|
|
|
1765
|
-
// Region tags + failover chain (#4).
|
|
1766
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1767
|
-
kind: 'exact',
|
|
1768
|
-
path: REGIONS_PATH,
|
|
1769
|
-
handler: (req, res) => {
|
|
1770
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1771
|
-
const body = regionMap.snapshot();
|
|
1772
|
-
// Add pickFallback hints per provider for inspection.
|
|
1773
|
-
const out = {};
|
|
1774
|
-
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1775
|
-
json(res, 200, out);
|
|
1776
|
-
},
|
|
1777
|
-
}), 'dsh-key-rotation: regions');
|
|
1778
|
-
|
|
1779
|
-
// Per-agent rate budget snapshot (#3).
|
|
1780
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1781
|
-
kind: 'exact',
|
|
1782
|
-
path: AGENT_BUDGET_PATH,
|
|
1783
|
-
handler: (req, res) => {
|
|
1784
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1785
|
-
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1786
|
-
},
|
|
1787
|
-
}), 'dsh-key-rotation: agent-budget');
|
|
1788
1597
|
|
|
1789
1598
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
1790
1599
|
if (options[MARKER]) return next();
|
|
@@ -1812,7 +1621,7 @@ export function apply(ctx, config = {}) {
|
|
|
1812
1621
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
1813
1622
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
1814
1623
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1815
|
-
const switchable =
|
|
1624
|
+
const switchable = isSwitchableError(payload, effectiveSwitchCodes);
|
|
1816
1625
|
if (!switchable) return next();
|
|
1817
1626
|
const ref = pool.state.lastUsed;
|
|
1818
1627
|
if (ref) {
|
|
@@ -1835,18 +1644,15 @@ export function apply(ctx, config = {}) {
|
|
|
1835
1644
|
});
|
|
1836
1645
|
}
|
|
1837
1646
|
|
|
1838
|
-
// Notify on exhaustion: webhook
|
|
1647
|
+
// Notify on exhaustion: webhook notification.
|
|
1839
1648
|
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
1840
1649
|
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
1841
|
-
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
1650
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
|
|
1842
1651
|
if (!runtime || !pool) return;
|
|
1843
1652
|
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
1844
1653
|
if (count <= 0) return;
|
|
1845
1654
|
try {
|
|
1846
1655
|
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
1847
|
-
// #199: interactive payload when an action token is configured - the
|
|
1848
|
-
// platform formatter (webhook.js) turns `actions` into buttons whose
|
|
1849
|
-
// callback carries the token back to /dsh-key-rotation/webhook-action.
|
|
1850
1656
|
const token = runtime.webhookActionToken ?? '';
|
|
1851
1657
|
const payload = {
|
|
1852
1658
|
title: `Key pool exhausted: ${options.provider}`,
|
|
@@ -1863,10 +1669,6 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
|
1863
1669
|
};
|
|
1864
1670
|
hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
1865
1671
|
}
|
|
1866
|
-
if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
|
|
1867
|
-
const reporter = hooks.ensureIncidentReporter();
|
|
1868
|
-
if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
|
|
1869
|
-
}
|
|
1870
1672
|
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1871
1673
|
}
|
|
1872
1674
|
|