@goodandready/dsh-key-rotation 0.8.41 → 0.8.43

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.43 - 2026-10-01
4
+
5
+ ### Fixed
6
+ - **Sanitized GitHub mirror and provenance workflow documentation (#425)**:
7
+ - Updated `docs/deployment/github-npm-provenance.md` to accurately document the Gitea-first development workflow and sanitized GitHub mirror export (`./scripts/publish-github.sh --push <tag>`), eliminating contradictions regarding direct GitHub PRs and external CI triggers.
8
+ - **LatencyHistogram configuration from latencyWindow setting (#426)**:
9
+ - Implemented dynamic `reconfigure({ window })` on `LatencyHistogram` (`lib/histogram.js`) and wired `cfg.latencyWindow` from configuration into `buildRuntime()` (`lib/index.js`), ensuring the configured latency sampling window is respected at runtime.
10
+ - **Auto-unbreak quarantine separation and safety checks (#427)**:
11
+ - Updated `lib/rotate.js` to place keys into a recoverable 30-minute quarantine (`brokenUntil`) after 3 consecutive non-auth failures while protecting rate-limited/quota-exhausted keys from quarantine pollution.
12
+ - Updated `lib/heal.js` to strictly skip permanently revoked (`revokedRefs`) and paused (`pausedRefs`) credentials during auto-unbreak background probing, preventing probing or accidental reactivation of revoked credentials.
13
+ - **Webhook Alert Digest pipeline integration (#428)**:
14
+ - Wired `AlertDebouncer` into the production notification pipeline in `lib/index.js`, batching rapid alert bursts into consolidated digest payloads before webhook dispatch as documented.
15
+
16
+ ## 0.8.42 - 2026-10-01
17
+
18
+ ### Fixed
19
+ - **Telemetry and budget monitor model pool coverage (#417)**:
20
+ - Fixed `/dsh-key-rotation/usage` export and background alert monitoring (`budget-monitor.js`) to iterate over `runtime.pools` instead of `poolByRef.values()`.
21
+ - Ensures model pools sharing physical credential refs (`provider::model`) are included in usage reports, CSV exports, and low-health/SLO alerts without double-counting aggregate credential totals.
22
+ - **Exposed circuit breaker state and notifyQueue status (#418)**:
23
+ - Passed `notifyQueue` to operational routes and attached `breaker` and `notifyQueue` to `buildRuntime()`.
24
+ - `/dsh-key-rotation/status` now reports `circuit` state (`open`, `half-open`, `closed`) per provider and includes `notifyQueue` pending/sent metrics in `meta`.
25
+ - **Bounded Telegram callback_data payload size (#419)**:
26
+ - Updated `formatInteractive` in `lib/webhook.js` so Telegram inline button `callback_data` payloads are strictly <= 64 bytes and omit raw master `actionToken`.
27
+ - Prevents Telegram API rejection (`BUTTON_DATA_INVALID`) while retaining Telegram `secret_token` header authentication and callback acknowledgement.
28
+ - **Structured key eligibility in Settings UI (#420)**:
29
+ - Updated `keyStatus` in `lib/client-src/50-card-main.js` to return structured eligibility flags (`ready`, `paused`, `revoked`, `expired`, `missing`, `cooling`).
30
+ - Settings card ready counters, header summaries, and ready filter pills no longer miscount paused, revoked, expired, or missing keys as ready. `optimisticReset` state is cleared upon receiving server status updates.
31
+ - **Single root UpdaterSection component (#421)**:
32
+ - Relocated `UpdaterSection` out of per-provider card row loops to the main card root container in `lib/client-src/50-card-main.js`.
33
+ - Ensures exactly one updater section is rendered regardless of provider count (0, 1, or N), enabling update checks even with 0 pools configured while eliminating duplicate GET requests.
34
+ - **Quota-aware Prometheus active_keys metric (#423)**:
35
+ - Updated Prometheus metrics exporter in `lib/ops-metrics.js` to verify model token quota availability when calculating `dsh_key_rotation_active_keys`.
36
+ - Added new `dsh_key_rotation_quota_exhausted_keys` gauge metric, aligning exporter readiness metrics with `/dsh-key-rotation/health`.
37
+
3
38
  ## 0.8.41 - 2026-10-01
4
39
 
5
40
  ### Fixed
@@ -55,7 +55,8 @@ export function checkBudgetAndHealthAlerts({
55
55
  }
56
56
 
57
57
  const seen = new Set();
58
- for (const pool of runtime.poolByRef.values()) {
58
+ const poolsToScan = runtime.pools ?? runtime.poolByRef.values();
59
+ for (const pool of poolsToScan) {
59
60
  if (seen.has(pool.base)) continue;
60
61
  seen.add(pool.base);
61
62
  // #207: keys expiring within expiryWarnDays -> one webhook per key/day
package/lib/client.js CHANGED
@@ -1789,26 +1789,27 @@ window.__ModuleLoader__.load({
1789
1789
  // Key state dot: color and label read at a glance; "key not found"
1790
1790
  // catches an env-name typo that would otherwise stay silent.
1791
1791
  const keyStatus = (providerId, ref, isPaused = false) => {
1792
- if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused') };
1792
+ if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused'), ready: false, paused: true };
1793
1793
  const hit = keyInfo(providerId, ref);
1794
1794
  if (!hit) return null;
1795
- if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
1796
- if (hit.expiresAt && !hit.expired) {
1797
- const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
1798
- const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
1799
- if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
1800
- }
1801
- if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
1802
- if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
1803
- if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
1795
+ if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired'), ready: false, expired: true };
1796
+ if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked'), ready: false, revoked: true };
1797
+ if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing'), ready: false, missing: true };
1804
1798
  if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
1805
1799
  return {
1806
1800
  color: 'var(--dsw-alias-state-warning-primary)',
1807
1801
  text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
1802
+ ready: false,
1803
+ cooling: true,
1808
1804
  };
1809
1805
  }
1810
- if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive') };
1811
- return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
1806
+ if (hit.expiresAt && !hit.expired) {
1807
+ const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
1808
+ const warnDays = Number(val?.expiryWarnDays) || 7;
1809
+ if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)), ready: true, expiringSoon: true };
1810
+ }
1811
+ if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive'), ready: true, active: true };
1812
+ return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady'), ready: true };
1812
1813
  };
1813
1814
 
1814
1815
  const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
@@ -1829,19 +1830,19 @@ window.__ModuleLoader__.load({
1829
1830
  for (let idx = 0; idx < keys.length; idx++) {
1830
1831
  const k = keys[idx];
1831
1832
  const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
1832
- const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
1833
+ const isCool = (st && st.cooling) && !optimisticReset[k];
1833
1834
  const tr = testResult[k];
1834
1835
  if (tr && !tr.ok) errorCount++;
1835
1836
  if (isCool) cooldownCount++;
1836
- else readyCount++;
1837
+ if (st && st.ready) readyCount++;
1837
1838
  }
1838
1839
 
1839
1840
  const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
1840
1841
  if (statusFilter === 'all') return true;
1841
1842
  const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
1842
- const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
1843
+ const isCool = (st && st.cooling) && !optimisticReset[k];
1843
1844
  const tr = testResult[k];
1844
- if (statusFilter === 'ready') return !isCool;
1845
+ if (statusFilter === 'ready') return Boolean(st && st.ready);
1845
1846
  if (statusFilter === 'cooldown') return isCool;
1846
1847
  if (statusFilter === 'error') return tr && !tr.ok;
1847
1848
  return true;
@@ -2224,7 +2225,6 @@ window.__ModuleLoader__.load({
2224
2225
  loadChart,
2225
2226
  h('div', { className: 'krot-keys' }, keyRows),
2226
2227
  modelPools,
2227
- h(UpdaterSection, { t }),
2228
2228
  h('div', { className: 'krot-foot' },
2229
2229
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
2230
2230
  btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
@@ -2303,8 +2303,7 @@ window.__ModuleLoader__.load({
2303
2303
  totalKeysCount += pKeys.length;
2304
2304
  for (const k of pKeys) {
2305
2305
  const st = keyStatus(prov.provider, k);
2306
- const isDown = st && (st.text === t('brokenKey') || st.text === t('keyMissing') || (st.text && st.text.includes(t('keyCooling').slice(0, 4)))) && !optimisticReset[k];
2307
- if (!isDown) healthyKeysCount++;
2306
+ if (st && st.ready) healthyKeysCount++;
2308
2307
  }
2309
2308
  }
2310
2309
 
@@ -2377,7 +2376,8 @@ window.__ModuleLoader__.load({
2377
2376
  h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
2378
2377
  btn(t('addProvider'), addProvider, { primary: false }),
2379
2378
  noProviders
2380
- )
2379
+ ),
2380
+ h(UpdaterSection, { t })
2381
2381
  );
2382
2382
 
2383
2383
  const failoverSection = h('div', { className: 'krot-section-card' },
package/lib/heal.js CHANGED
@@ -54,6 +54,7 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), {
54
54
  if (!bu || bu.size === 0) continue;
55
55
  const brokenRefs = [];
56
56
  for (const [ref, until] of bu.entries()) {
57
+ if (pool.state.revokedRefs?.has(ref) || pool.state.pausedRefs?.has(ref)) continue;
57
58
  if (Number.isFinite(until) && until > now) {
58
59
  brokenRefs.push(ref);
59
60
  }
package/lib/histogram.js CHANGED
@@ -4,11 +4,43 @@
4
4
  export const LATENCY_DEFAULT_WINDOW = 200;
5
5
 
6
6
  export class LatencyHistogram {
7
- constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
7
+ constructor(opts = {}) {
8
+ const windowVal = typeof opts === 'number' ? opts : opts?.window;
9
+ this.reconfigure({ window: windowVal });
10
+ }
11
+
12
+ get window() {
13
+ return this._window;
14
+ }
15
+
16
+ reconfigure({ window = LATENCY_DEFAULT_WINDOW } = {}) {
8
17
  const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
18
+ if (this._window === w && this._buffers) return;
19
+
20
+ if (!this._buffers) {
21
+ this._window = w;
22
+ this._buffers = new Map(); // ref -> { buf, head, count }
23
+ this._lastAt = new Map(); // ref -> epochMs
24
+ return;
25
+ }
26
+
27
+ // Resize existing ring buffers to new length w
28
+ for (const [ref, entry] of this._buffers.entries()) {
29
+ const samples = [];
30
+ const start = entry.count < entry.buf.length ? 0 : entry.head;
31
+ for (let i = 0; i < entry.count; i++) {
32
+ samples.push(entry.buf[(start + i) % entry.buf.length]);
33
+ }
34
+ const newSamples = samples.slice(-w);
35
+ const newBuf = new Float64Array(w);
36
+ for (let i = 0; i < newSamples.length; i++) {
37
+ newBuf[i] = newSamples[i];
38
+ }
39
+ entry.buf = newBuf;
40
+ entry.head = newSamples.length % w;
41
+ entry.count = newSamples.length;
42
+ }
9
43
  this._window = w;
10
- this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
11
- this._lastAt = new Map(); // ref -> epochMs of last sample
12
44
  }
13
45
 
14
46
  record(ref, ms) {
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  import { LatencyHistogram } from './histogram.js';
21
21
  import { ConcurrencyTracker } from './concurrency.js';
22
22
  import { QuotaStore } from './quota.js';
23
- import { WebhookSender } from './webhook.js';
23
+ import { WebhookSender, AlertDebouncer } from './webhook.js';
24
24
  import { compactUsage } from './usage-report.js';
25
25
  import { json, handleConfigBridge } from './http-bridge.js';
26
26
  import { createRotate } from './rotate.js';
@@ -80,7 +80,8 @@ const concurrencyTracker = new ConcurrencyTracker();
80
80
 
81
81
  let moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
82
82
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
83
- let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
83
+ const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
84
+ let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
84
85
 
85
86
  let getConfig = () => null;
86
87
  function verboseLoggingOn() {
@@ -270,6 +271,9 @@ export function apply(ctx, config = {}) {
270
271
  openMs: cfg.circuitBreakerOpenMs ?? 30000,
271
272
  halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
272
273
  });
274
+ if (cfg.latencyWindow && latencyHistogram.window !== cfg.latencyWindow) {
275
+ latencyHistogram.reconfigure({ window: cfg.latencyWindow });
276
+ }
273
277
  const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
274
278
  const cooldownMs = cfg.cooldownMs ?? 60000;
275
279
  const maxCooldownMs = cfg.maxCooldownMs;
@@ -336,6 +340,7 @@ export function apply(ctx, config = {}) {
336
340
  switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
337
341
  warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
338
342
  index, pools,
343
+ breaker: moduleBreaker, notifyQueue: moduleNotifyQueue,
339
344
  providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
340
345
  cloneIds, expectedClones,
341
346
  circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
@@ -426,6 +431,7 @@ export function apply(ctx, config = {}) {
426
431
  getRotationDisabled: () => rotationDisabled,
427
432
  setRotationDisabled: (v) => { rotationDisabled = v; },
428
433
  circuitBreaker: moduleBreaker,
434
+ notifyQueue: moduleNotifyQueue,
429
435
  quotaStore,
430
436
  });
431
437
 
@@ -1,6 +1,7 @@
1
1
  // lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
2
2
  import { METRICS_PATH } from './ops-paths.js';
3
3
  import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
4
+ import { hasModelQuotaConfig, isModelQuotaAvailable } from './model-quota.js';
4
5
 
5
6
  /**
6
7
  * @param {object} ctx cordis context
@@ -50,6 +51,9 @@ export function registerMetricsRoutes(ctx, deps) {
50
51
  lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
51
52
  lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
52
53
 
54
+ lines.push('# HELP dsh_key_rotation_quota_exhausted_keys Number of keys blocked by model token quota');
55
+ lines.push('# TYPE dsh_key_rotation_quota_exhausted_keys gauge');
56
+
53
57
  lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
54
58
  lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
55
59
 
@@ -59,11 +63,13 @@ export function registerMetricsRoutes(ctx, deps) {
59
63
  for (const pool of pools) {
60
64
  const p = pool.base;
61
65
  const st = poolState.get(p);
62
- let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
66
+ const quotaAware = hasModelQuotaConfig(pool);
67
+ let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0, quotaExhausted = 0;
63
68
  for (const r of (pool.refs ?? [])) {
64
69
  if (isKeyRevoked(pool, r)) { revoked++; continue; }
65
70
  if (isKeyPaused(pool, r)) { paused++; continue; }
66
71
  if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
72
+ if (quotaAware && !isModelQuotaAvailable(pool, r, now)) { quotaExhausted++; continue; }
67
73
  const fu = st?.failedUntil?.get(r) ?? 0;
68
74
  if (fu > now) cooling++;
69
75
  else active++;
@@ -73,6 +79,7 @@ export function registerMetricsRoutes(ctx, deps) {
73
79
  lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
74
80
  lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
75
81
  lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
82
+ lines.push(`dsh_key_rotation_quota_exhausted_keys{provider="${p}"} ${quotaExhausted}`);
76
83
  lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
77
84
  lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
78
85
  }
package/lib/ops-status.js CHANGED
@@ -29,6 +29,7 @@ export function registerStatusRoutes(ctx, deps) {
29
29
  buildRuntime,
30
30
  latencyHistogram,
31
31
  circuitBreaker,
32
+ notifyQueue,
32
33
  quotaStore,
33
34
  } = deps;
34
35
 
@@ -89,7 +90,7 @@ ctx.effect(() => ctx.webServer.register({
89
90
  } catch {
90
91
  /* describe is optional — the card falls back to editable */
91
92
  }
92
- const until = pool.state.failedUntil.get(ref);
93
+ const until = pool.state.failedUntil?.get(ref);
93
94
  const modelQuota = getModelQuotaStatus(pool, ref, now);
94
95
  keys.push({
95
96
  ref,
@@ -123,10 +124,10 @@ ctx.effect(() => ctx.webServer.register({
123
124
  model: pool.model ?? null,
124
125
  quotasConfigured: hasModelQuotaConfig(pool),
125
126
  keys,
126
- tags: providerTags.get(pool.base) ?? [],
127
+ tags: providerTags?.get?.(pool.base) ?? [],
127
128
  // #260 circuit breaker state (may be null if not yet tripped)
128
129
  circuit: (() => {
129
- const br = runtime.breaker;
130
+ const br = circuitBreaker ?? runtime.breaker;
130
131
  if (!br) return null;
131
132
  const st = br.state(pool.base);
132
133
  return { state: st, threshold: br.threshold, openMs: br.openMs };
@@ -164,7 +165,7 @@ ctx.effect(() => ctx.webServer.register({
164
165
  // #266/#263 operational extras (additive)
165
166
  meta: {
166
167
  expectedClones: [...(runtime.expectedClones ?? [])],
167
- notifyQueue: runtime.notifyQueue?.stats?.() ?? null,
168
+ notifyQueue: (notifyQueue ?? runtime.notifyQueue)?.stats?.() ?? (notifyQueue ?? runtime.notifyQueue)?.snapshot?.() ?? null,
168
169
  breakerEnabled: runtime.circuitBreakerEnabled !== false,
169
170
  at: now,
170
171
  },
@@ -197,7 +198,7 @@ ctx.effect(() => ctx.webServer.register({
197
198
  let quotaExhausted = 0;
198
199
  for (const ref of pool.refs) {
199
200
  if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
200
- const until = pool.state.failedUntil.get(ref);
201
+ const until = pool.state.failedUntil?.get(ref);
201
202
  if (until !== undefined && until > now) continue;
202
203
  const exp = pool.expiresAt?.[ref];
203
204
  if (exp !== undefined && now >= exp) continue;
@@ -40,10 +40,11 @@ ctx.effect(() => ctx.webServer.register({
40
40
  const now = Date.now();
41
41
  const seen = new Set();
42
42
  const report = [];
43
- for (const pool of runtime.poolByRef.values()) {
43
+ const poolsToScan = runtime.pools ?? runtime.poolByRef.values();
44
+ for (const pool of poolsToScan) {
44
45
  if (seen.has(pool.base)) continue;
45
46
  seen.add(pool.base);
46
- if (provider && pool.base !== provider) continue;
47
+ if (provider && pool.base !== provider && pool.provider !== provider) continue;
47
48
  report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
48
49
  }
49
50
  if (csv) {
package/lib/rotate.js CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  formatModelQuotaExhaustion,
27
27
  } from './model-quota.js';
28
28
 
29
+ import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
30
+
29
31
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
30
32
 
31
33
  export async function* preserveDispatchContext(iterable, storage, store) {
@@ -186,6 +188,16 @@ export function createRotate(deps) {
186
188
  logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently REVOKED on auth failure`);
187
189
  } else {
188
190
  pool.state.authFailCounts.delete(targetRef);
191
+ if (!isQuota) {
192
+ const _fc = (pool.state.failCounts.get(targetRef) ?? 0) + 1;
193
+ pool.state.failCounts.set(targetRef, _fc);
194
+ if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
195
+ const quarantineMs = 1800000;
196
+ pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
197
+ pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
198
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
199
+ }
200
+ }
189
201
  }
190
202
  };
191
203
 
package/lib/webhook.js CHANGED
@@ -31,10 +31,19 @@ export function formatInteractive(url, payload, actionToken) {
31
31
  text: `*${title}*\n${text}`,
32
32
  parse_mode: 'Markdown',
33
33
  reply_markup: {
34
- inline_keyboard: [actions.map((a) => ({
35
- text: a.label,
36
- callback_data: JSON.stringify({ id: a.id, token: actionToken }),
37
- }))],
34
+ inline_keyboard: [actions.map((a) => {
35
+ let cbData = actionToken ? JSON.stringify({ id: a.id, token: actionToken }) : JSON.stringify({ id: a.id });
36
+ if (cbData.length > 64) {
37
+ cbData = JSON.stringify({ id: a.id });
38
+ }
39
+ if (cbData.length > 64) {
40
+ cbData = String(a.id).slice(0, 64);
41
+ }
42
+ return {
43
+ text: a.label,
44
+ callback_data: cbData,
45
+ };
46
+ })],
38
47
  },
39
48
  };
40
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.41",
3
+ "version": "0.8.43",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [