@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/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 { expiringSoon, shouldNotifyDaily, costForDay, budgetVerdict, costForWeek } from './maintenance.js';
76
- import { usageRows, usageCsv } from './usage-report.js';
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
- // Dashboard widget now lives in client.js (mountDashboard, see issue #152).
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
- // 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() ?? {})) ?? {};
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
- 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 };
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 = pool && pool.state && pool.state.lastUsed;
834
+ const ref = reqStore?.pickedRef ?? pool?.state?.lastUsed;
973
835
  if (!ref) return;
974
- const elapsed = Date.now() - _rotateStartMs;
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
- _rotateStartMs = Date.now();
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 = (pool.weightedRefs ?? pool.refs).filter((r) => {
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 && (pool.weightedRefs ?? pool.refs)[0] !== preferred) {
1001
- // Move preferred to front of the attempt list
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
- pool.weightedRefs = list;
869
+ attemptList = list;
1006
870
  }
1007
871
  }
1008
- for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
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
- if (pool.state.lastUsed) { const _retry = parseRetryAfter(String(e?.message ?? '')); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); const _code = String(e?.code ?? ''); if (_code === 'AUTH' || /auth/i.test(String(e?.message ?? ''))) { const _c = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c); if (_c >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
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(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
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
- (effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
929
+ const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
930
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
1042
931
  if (switchable) {
1043
- if (pool.state.lastUsed) {
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(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
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 && pool.state.lastUsed) {
939
+ if (buildRuntime().switchNotify && activeRef) {
1072
940
  notifySwitch(buildRuntime(), pool, {
1073
941
  provider: options.provider,
1074
- from: pool.state.lastUsed,
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 && pool.state.lastUsed) {
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(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
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(pool.state.lastUsed) || new Map();
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(pool.state.lastUsed, cMap);
961
+ pool.state.costDays.set(activeRef, cMap);
1094
962
  }
1095
963
  }
1096
964
  // Usage by day (#119)
1097
- if (pool.state.lastUsed) {
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(pool.state.lastUsed) || new Map();
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(pool.state.lastUsed, dayMap);
970
+ pool.state.usageDays.set(activeRef, dayMap);
1103
971
  }
1104
972
  // Per-model request detail (#121)
1105
- if (pool.state.lastUsed && options.model) {
973
+ if (activeRef && options.model) {
1106
974
  if (!pool.state.byModel) pool.state.byModel = new Map();
1107
- let byRef = pool.state.byModel.get(pool.state.lastUsed);
1108
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(pool.state.lastUsed, byRef); }
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 && pool.state.lastUsed) {
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, pool.state.lastUsed, Date.now(), cool, pool.maxCooldownMs);
1120
- pushEvent(pool, pool.state.lastUsed, 'RATE_LIMIT', cool);
1121
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
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 && pool.state.lastUsed && Number.isFinite(rate.remaining)) {
1126
- quotaStore.set(pool.state.lastUsed, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
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
- yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
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 = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
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 + (optional) GitHub incident.
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, ensureIncidentReporter }) {
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