@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/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
- // Dashboard widget now lives in client.js (mountDashboard, see issue #152).
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
- // Deep-clone before resolving: the frozen snapshot from settings.register
725
- // must never be written to by schemastery's dict resolver.
726
- const cfg = Config(structuredClone(getConfig() ?? {})) ?? {};
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
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, 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 };
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
- const credentials = ctx.get('credentials');
866
- if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
867
- const original = credentials.resolve.bind(credentials);
868
- // Kept for the status route: it must ask about one exact ref instead of
869
- // being rotated to a different key by the patch below.
870
- credentials.__dshKeyRotationOriginalResolve = original;
871
- credentials.resolve = async (ref) => {
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
- credentials.__dshKeyRotationPatched = true;
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
- yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
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 = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
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 + (optional) GitHub incident.
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, ensureIncidentReporter }) {
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