@goodandready/dsh-key-rotation 0.7.35 → 0.7.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -253,6 +253,14 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
253
253
 
254
254
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
255
 
256
+ ### v0.7.36
257
+ - **Architecture de-bloat & hardening**: Removed 6 unused/overengineered modules (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) and dead route registrations.
258
+ - **High-throughput buildRuntime memoization**: Eliminates per-token deep-cloning and schema validation on every streaming chunk.
259
+ - **Atomic round-robin pointer rotation**: Concurrent requests advance pointer immediately on candidate selection, eliminating race conditions on simultaneous tool calls.
260
+ - **Enhanced switchable error detection**: Direct parsing of HTTP status codes (`429`, `401`, `403`, `5xx`) and gRPC codes (`RESOURCE_EXHAUSTED`, `UNAVAILABLE`) alongside regex fallback.
261
+ - **Informative user exhaustion messaging**: Clear countdown notice with next key recovery ETA when all keys in a pool are cooling down.
262
+ - **Smart polling**: Client background polling paused when browser tab is inactive (`document.visibilityState`).
263
+
256
264
  ### v0.7.35
257
265
  - **Lifecycle Cleanups**: Wrapped `credentials.resolve` patch and `ctx.on` event handlers (`llm/stream`, `agent/request-error`) in `ctx.effect` scopes with guaranteed unmount cleanup (#238, #239).
258
266
  - **Settings & Secret Roles**: Added `.role('secret')` to `incidentGitHubToken` and `webhookActionToken` in `Config` schema for automatic UI masking (#237).
package/README.ru.md CHANGED
@@ -253,6 +253,14 @@ dsh-key-rotation:
253
253
 
254
254
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
255
 
256
+ ### v0.7.36
257
+ - **Глубокий рефакторинг и деблоатинг**: Удалены 6 неиспользуемых модулей оверинжиниринга (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) и сопутствующие устаревшие эндпоинты.
258
+ - **Мемоизация buildRuntime**: Устранено глубокое клонирование и повторный парсинг схемы на каждом токене/чанге стрима.
259
+ - **Атомарная ротация указателя**: Устранено состояние гонки при параллельных вызовах инструментов — указатель смещается сразу при выборе кандидата.
260
+ - **Точная детекция ошибок**: Прямая проверка кодов HTTP (`429`, `401`, `403`, `5xx`) и gRPC (`RESOURCE_EXHAUSTED`, `UNAVAILABLE`) с фоллбеком на регулярные выражения.
261
+ - **Понятные сообщения при исчерпании пула**: Информативное уведомление с таймером восстановления ближайшего ключа.
262
+ - **Энергоэффективный опрос (Smart Polling)**: Автоматическая приостановка фонового опроса при неактивной вкладке браузера (`document.visibilityState`).
263
+
256
264
  ### v0.7.35
257
265
  - **Очистка жизненного цикла**: Патч `credentials.resolve` и слушатели событий `ctx.on` (`llm/stream`, `agent/request-error`) переведены в скоупы `ctx.effect` с автоматическим восстановлением функций и отпиской при выгрузке плагина (#238, #239).
258
266
  - **Роли секретов в схеме**: Полям `incidentGitHubToken` и `webhookActionToken` в схеме `Config` присвоена роль `.role('secret')` для маскирования в UI (#237).
package/README.zh.md CHANGED
@@ -214,6 +214,14 @@ dsh-key-rotation:
214
214
 
215
215
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
216
216
 
217
+ ### v0.7.36
218
+ - **架构精简与稳定性加固**:移除 6 个过度设计的模块(`shadow`、`incident`、`agent-budget`、`region`、`canary`、`maintenance`)与废弃端点。
219
+ - **buildRuntime 高性能记忆化**:消除每个流式 token/chunk 上的深拷贝与模式重解析开销。
220
+ - **原子轮询指针推进**:并发请求在选定候选密钥时立即推进指针,消除并发工具调用中的竞争条件。
221
+ - **增强的可切换错误检测**:直接解析 HTTP 状态码(`429`, `401`, `403`, `5xx`)与 gRPC 状态码(`RESOURCE_EXHAUSTED`, `UNAVAILABLE`)。
222
+ - **用户友好的耗尽提示**:密钥池耗尽时返回带恢复倒计时的清晰通知。
223
+ - **智能轮询(Smart Polling)**:标签页不活动时暂停客户端后台轮询。
224
+
217
225
  ### v0.7.35
218
226
  - **生命周期清理**: 将 `credentials.resolve` 猴子补丁和 `ctx.on` 事件监听器 (`llm/stream`, `agent/request-error`) 封装在 `ctx.effect` 作用域内,确保卸载时自动注销并恢复原始方法 (#238, #239)。
219
227
  - **配置密钥角色**: 在 `Config` Schema 中为 `incidentGitHubToken` 和 `webhookActionToken` 增加 `.role('secret')`,避免明文泄露并在 UI 中掩码显示 (#237)。
package/lib/client.js CHANGED
@@ -240,12 +240,13 @@ window.__ModuleLoader__.load({
240
240
  // отдельными отмеченными галочками, чтобы правило нельзя было потерять.
241
241
  const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
242
242
 
243
- /** Опрос статуса ротации, пока раздел настроек открыт. */
243
+ /** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
244
244
  function useRotationStatus() {
245
245
  const [byProvider, setByProvider] = React.useState({});
246
246
  React.useEffect(() => {
247
247
  let alive = true;
248
248
  const pull = () => {
249
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
249
250
  fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
250
251
  .then((r) => (r.ok ? r.json() : null))
251
252
  .then((data) => {
@@ -258,17 +259,24 @@ window.__ModuleLoader__.load({
258
259
  };
259
260
  pull();
260
261
  const id = setInterval(pull, 4000);
261
- return () => { alive = false; clearInterval(id); };
262
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
263
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
264
+ return () => {
265
+ alive = false;
266
+ clearInterval(id);
267
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
268
+ };
262
269
  }, []);
263
270
  return byProvider;
264
271
  }
265
272
 
266
- /** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
273
+ /** Последний probe-результат по каждому ключу (#219): /sandbox-cache (smart polling). */
267
274
  function useProbeCache() {
268
275
  const [cache, setCache] = React.useState({});
269
276
  React.useEffect(() => {
270
277
  let alive = true;
271
278
  const pull = () => {
279
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
272
280
  fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
273
281
  .then((r) => (r.ok ? r.json() : null))
274
282
  .then((data) => { if (alive && data) setCache(data); })
@@ -276,7 +284,13 @@ window.__ModuleLoader__.load({
276
284
  };
277
285
  pull();
278
286
  const id = setInterval(pull, 4000);
279
- return () => { alive = false; clearInterval(id); };
287
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
288
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
289
+ return () => {
290
+ alive = false;
291
+ clearInterval(id);
292
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
293
+ };
280
294
  }, []);
281
295
  return cache;
282
296
  }
@@ -1076,14 +1090,21 @@ window.__ModuleLoader__.load({
1076
1090
  React.useEffect(() => {
1077
1091
  let alive = true;
1078
1092
  const load = () => {
1093
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
1079
1094
  fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
1080
1095
  .then((r) => (r.ok ? r.json() : null))
1081
1096
  .then((d) => { if (alive) setSnap(d); })
1082
1097
  .catch(() => {});
1083
1098
  };
1084
1099
  load();
1085
- const id = setInterval(load, 4000);
1086
- return () => { alive = false; clearInterval(id); };
1100
+ const id = setInterval(load, 5000);
1101
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
1102
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
1103
+ return () => {
1104
+ alive = false;
1105
+ clearInterval(id);
1106
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
1107
+ };
1087
1108
  }, []);
1088
1109
 
1089
1110
  React.useEffect(() => {
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().role('secret').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(),
@@ -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,7 +705,10 @@ 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 ──
@@ -904,9 +756,10 @@ export function apply(ctx, config = {}) {
904
756
  continue;
905
757
  }
906
758
  }
759
+ // Advance pointer immediately so concurrent requests round-robin across distinct healthy keys
760
+ pool.state.pointer = (index + 1) % list.length;
907
761
  let hit = await original(candidate);
908
762
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
909
- pool.state.pointer = (index + 1) % list.length;
910
763
  pool.state.lastUsed = candidate;
911
764
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
912
765
  pool.state.failedUntil.delete(candidate);
@@ -926,7 +779,6 @@ export function apply(ctx, config = {}) {
926
779
  // fallback: env var (transient, not persisted)
927
780
  const envVal = envValue(candidate);
928
781
  if (envVal !== undefined) {
929
- pool.state.pointer = (index + 1) % list.length;
930
782
  pool.state.lastUsed = candidate;
931
783
  if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
932
784
  pool.state.failedUntil.delete(candidate);
@@ -1037,8 +889,7 @@ export function apply(ctx, config = {}) {
1037
889
  const code = failure?.code;
1038
890
  const message = failure?.message ?? '';
1039
891
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
1040
- const switchable = !yielded && kind === 'error' &&
1041
- (effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
892
+ const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
1042
893
  if (switchable) {
1043
894
  if (pool.state.lastUsed) {
1044
895
  const _retry = parseRetryAfter(message);
@@ -1167,7 +1018,8 @@ export function apply(ctx, config = {}) {
1167
1018
  }
1168
1019
  }
1169
1020
 
1170
- yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
1021
+ const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
1022
+ yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
1171
1023
  })();
1172
1024
  }
1173
1025
 
@@ -1337,7 +1189,6 @@ export function apply(ctx, config = {}) {
1337
1189
  const exportable = { ...value };
1338
1190
  // token-shaped fields stay empty in the file; refs are names, not secrets
1339
1191
  exportable.webhookActionToken = '';
1340
- if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
1341
1192
  json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
1342
1193
  return;
1343
1194
  }
@@ -1349,7 +1200,6 @@ export function apply(ctx, config = {}) {
1349
1200
  // #200 leak guard applies to imported content too
1350
1201
  try {
1351
1202
  const masked = structuredClone(snap);
1352
- if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
1353
1203
  if (masked.webhookActionToken) masked.webhookActionToken = '***';
1354
1204
  if (masked.notifyWebhook) masked.notifyWebhook = '***';
1355
1205
  const findings = findSecrets(JSON.stringify(masked));
@@ -1363,7 +1213,6 @@ export function apply(ctx, config = {}) {
1363
1213
  // empty token fields in the file keep the current values (never wipe a secret)
1364
1214
  const merged = { ...cur, ...snap };
1365
1215
  if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
1366
- if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
1367
1216
  try {
1368
1217
  await settings.replace(NS, merged, desc.revision);
1369
1218
  const after = descriptorOf(ctx, NS);
@@ -1609,61 +1458,7 @@ export function apply(ctx, config = {}) {
1609
1458
  },
1610
1459
  }), 'dsh-key-rotation: sandbox cache');
1611
1460
 
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
1461
 
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
-
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
1462
 
1668
1463
  // #199 webhook-action: interactive webhook buttons call back here.
1669
1464
  // Auth: bearer token from Config (external services like Telegram/Discord
@@ -1752,39 +1547,7 @@ export function apply(ctx, config = {}) {
1752
1547
  },
1753
1548
  }), 'dsh-key-rotation: webhook-action');
1754
1549
 
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
1550
 
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
1551
 
1789
1552
  ctx.effect(() => ctx.on('llm/stream', (options, next) => {
1790
1553
  if (options[MARKER]) return next();
@@ -1812,7 +1575,7 @@ export function apply(ctx, config = {}) {
1812
1575
  const code = String(payload?.failure?.code ?? payload?.code ?? '');
1813
1576
  const message = String(payload?.failure?.message ?? payload?.message ?? '');
1814
1577
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
1815
- const switchable = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
1578
+ const switchable = isSwitchableError(payload, effectiveSwitchCodes);
1816
1579
  if (!switchable) return next();
1817
1580
  const ref = pool.state.lastUsed;
1818
1581
  if (ref) {
@@ -1835,18 +1598,15 @@ export function apply(ctx, config = {}) {
1835
1598
  });
1836
1599
  }
1837
1600
 
1838
- // Notify on exhaustion: webhook + (optional) GitHub incident.
1601
+ // Notify on exhaustion: webhook notification.
1839
1602
  // Extracted at module scope for testability. No I/O outside the injected hooks.
1840
1603
  // 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 }) {
1604
+ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
1842
1605
  if (!runtime || !pool) return;
1843
1606
  const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
1844
1607
  if (count <= 0) return;
1845
1608
  try {
1846
1609
  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
1610
  const token = runtime.webhookActionToken ?? '';
1851
1611
  const payload = {
1852
1612
  title: `Key pool exhausted: ${options.provider}`,
@@ -1863,10 +1623,6 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
1863
1623
  };
1864
1624
  hooks.webhookSender.send(runtime.notifyWebhook, payload);
1865
1625
  }
1866
- if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
1867
- const reporter = hooks.ensureIncidentReporter();
1868
- if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
1869
- }
1870
1626
  } catch (_) { /* ponytail: never crash rotate() */ }
1871
1627
  }
1872
1628
 
package/lib/pool.js CHANGED
@@ -44,6 +44,10 @@ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
44
44
  /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
45
45
  /\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
46
46
  /\bbilling\b/i,
47
+ /\bresource[\s_-]+exhausted\b/i,
48
+ /\bcapacity[\s_-]+(?:limit|exceeded|reached)\b/i,
49
+ /\bfree[\s_-]+tier[\s_-]+(?:limit|exceeded)\b/i,
50
+ /\bconcurrent[\s_-]+(?:requests?|limit)[\s_-]+exceeded\b/i,
47
51
  /\b429\b|\b5\d\d\b/i,
48
52
  /\btime(?:d)?\s*out\b|timeout/i,
49
53
  /\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
@@ -52,6 +56,7 @@ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
52
56
  /\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
53
57
  /\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
54
58
  /\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
59
+ /\b(?:overloaded|server[\s_-]+busy)\b/i,
55
60
  ].map((r) => r.source).join('|'), 'i');
56
61
 
57
62
  /** Default switch codes used by lib/index.js. */
@@ -261,4 +266,108 @@ export function isRateLimited(rate, threshold = 0.1) {
261
266
  if (rate.remaining === undefined) return false;
262
267
  if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
263
268
  return rate.remaining <= 0;
264
- }
269
+ }
270
+
271
+ /**
272
+ * Unified check whether an error or payload represents a switchable failure.
273
+ * Prioritizes explicit HTTP status codes and gRPC codes before text matching.
274
+ */
275
+ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAULT_SWITCH_CODES)) {
276
+ if (!failureOrPayload) return false;
277
+ const failure = failureOrPayload.failure ?? failureOrPayload;
278
+ const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
279
+ const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
280
+ const message = String(failure.message ?? failureOrPayload.message ?? '');
281
+
282
+ // 1. Direct HTTP status codes
283
+ if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
284
+ if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
285
+ if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
286
+
287
+ // 2. Standard gRPC / cloud error codes
288
+ if (code === 'RESOURCE_EXHAUSTED' && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT'))) return true;
289
+ if ((code === 'UNAVAILABLE' || code === 'INTERNAL') && switchCodes.has('SERVER')) return true;
290
+ if (code === 'DEADLINE_EXCEEDED' && switchCodes.has('TIMEOUT')) return true;
291
+ if ((code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED') && switchCodes.has('AUTH')) return true;
292
+
293
+ // 3. Named switch code matching
294
+ if (code && switchCodes.has(code)) return true;
295
+
296
+ // 4. Fallback text pattern matching
297
+ return SWITCHABLE_MESSAGE_PATTERN.test(message);
298
+ }
299
+
300
+ /**
301
+ * Format an informative user-facing exhaustion message with recovery countdown.
302
+ */
303
+ export function formatExhaustionMessage(provider, pool, now = Date.now()) {
304
+ const list = pool.refs ?? [];
305
+ const total = list.length;
306
+ let minWaitMs = Infinity;
307
+ if (pool.state && pool.state.failedUntil) {
308
+ for (const ref of list) {
309
+ const until = pool.state.failedUntil.get(ref) ?? 0;
310
+ if (until > now) {
311
+ const wait = until - now;
312
+ if (wait < minWaitMs) minWaitMs = wait;
313
+ }
314
+ }
315
+ }
316
+ const sec = Number.isFinite(minWaitMs) && minWaitMs > 0 ? Math.ceil(minWaitMs / 1000) : 60;
317
+ return `[dsh-key-rotation] All ${total} keys for provider '${provider}' are temporarily exhausted. Next key recovers in ~${sec}s.`;
318
+ }
319
+
320
+
321
+ /**
322
+ * Keys of pool whose expiresAt falls within the next warnDays.
323
+ * Returns [{ ref, expiresInDays, expiresAt }], soonest first.
324
+ */
325
+ export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
326
+ if (!pool || !pool.expiresAt) return [];
327
+ const DAY_MS = 86400000;
328
+ const horizon = now + Math.max(1, warnDays) * DAY_MS;
329
+ return Object.entries(pool.expiresAt)
330
+ .filter(([, at]) => at > now && at <= horizon)
331
+ .map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
332
+ .sort((a, b) => a.expiresAt - b.expiresAt);
333
+ }
334
+
335
+ /** Dedupe: true when a day-level notification for key is due. */
336
+ export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
337
+ const DAY_MS = 86400000;
338
+ if (!lastNotified.has(key)) {
339
+ lastNotified.set(key, now);
340
+ return true;
341
+ }
342
+ const last = lastNotified.get(key);
343
+ if (now - last < DAY_MS) return false;
344
+ lastNotified.set(key, now);
345
+ return true;
346
+ }
347
+
348
+ /** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
349
+ export function costForDay(costDays, day) {
350
+ const d = day ?? new Date().toISOString().slice(0, 10);
351
+ let total = 0;
352
+ for (const perRef of (costDays?.values() ?? [])) {
353
+ total += perRef.get(d) ?? 0;
354
+ }
355
+ return total;
356
+ }
357
+
358
+ /** Total spend over the last 7 ISO days ending today. */
359
+ export function costForWeek(costDays, now = Date.now()) {
360
+ let total = 0;
361
+ for (let i = 0; i < 7; i++) {
362
+ const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
363
+ total += costForDay(costDays, d);
364
+ }
365
+ return total;
366
+ }
367
+
368
+ /** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
369
+ export function budgetVerdict(spend, budget) {
370
+ if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
371
+ const ratio = spend / budget;
372
+ return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
373
+ }
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.35",
4
- "packageManager": "pnpm@10.33.2",
3
+ "version": "0.7.36",
5
4
  "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
5
  "keywords": [
7
6
  "deepseek-harness",
@@ -56,4 +55,4 @@
56
55
  "scripts": {
57
56
  "test": "node --test test/*.test.js test/*.test.mjs"
58
57
  }
59
- }
58
+ }
@@ -1,68 +0,0 @@
1
- // lib/agent-budget.js — per-agent rate cap.
2
- // ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
3
-
4
- export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
5
- export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
6
- export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
7
-
8
- export class AgentBudget {
9
- constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
10
- const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
11
- const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
12
- this._windowMs = w;
13
- this._limit = l;
14
- this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
15
- }
16
-
17
- isEnabled() {
18
- return this._limit > 0;
19
- }
20
-
21
- // Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
22
- // Records the hit only when allowed.
23
- check(agentId, now = Date.now()) {
24
- if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
25
- if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
26
- let s = this._state.get(agentId);
27
- if (!s) {
28
- s = { hits: [], windowStart: now };
29
- this._state.set(agentId, s);
30
- }
31
- // Window: prune hits older than windowStart + windowMs
32
- const cutoff = now - this._windowMs;
33
- while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
34
- s.windowStart = s.hits.length ? s.hits[0] : now;
35
- if (s.hits.length >= this._limit) {
36
- const resetAt = s.hits[0] + this._windowMs;
37
- return { allowed: false, remaining: 0, resetAt };
38
- }
39
- s.hits.push(now);
40
- return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
41
- }
42
-
43
- // Reset single agent or all
44
- reset(agentId) {
45
- if (agentId) this._state.delete(agentId);
46
- else this._state.clear();
47
- }
48
-
49
- // Inspect-only: return remaining without recording.
50
- peek(agentId, now = Date.now()) {
51
- if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
52
- const s = this._state.get(agentId);
53
- if (!s) return { remaining: this._limit, resetAt: null };
54
- const cutoff = now - this._windowMs;
55
- let count = 0;
56
- for (let i = 0; i < s.hits.length; i++) {
57
- if (s.hits[i] >= cutoff) count += 1;
58
- }
59
- const oldest = s.hits[0];
60
- return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
61
- }
62
-
63
- snapshot() {
64
- const out = {};
65
- for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
66
- return out;
67
- }
68
- }
package/lib/canary.js DELETED
@@ -1,63 +0,0 @@
1
- // canary.js — canary probing before releasing a key from cooldown (issue #196, #7).
2
-
3
- export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
- export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
-
6
- export class CanaryProber {
7
- constructor(opts) {
8
- opts = opts || {};
9
- if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
- this._runner = opts.sandboxRunner;
11
- this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
- this._probeTargetModel = Boolean(opts.probeTargetModel);
13
- this._results = new Map();
14
- this._inProgress = new Set();
15
- }
16
-
17
- get intervalMs() { return this._intervalMs; }
18
- get probeTargetModel() { return this._probeTargetModel; }
19
-
20
- async probe(ref, key, targetModel = null) {
21
- if (!ref || this._inProgress.has(ref)) return null;
22
- this._inProgress.add(ref);
23
- try {
24
- let result;
25
- if (this._probeTargetModel && targetModel && typeof this._runner.probeChatCompletion === 'function') {
26
- result = await this._runner.probeChatCompletion(ref, key, targetModel);
27
- } else {
28
- result = await this._runner.probeModels(ref, key);
29
- }
30
- this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
31
- return result;
32
- } catch (e) {
33
- return { ok: false, code: 'error', at: Date.now() };
34
- } finally {
35
- this._inProgress.delete(ref);
36
- }
37
- }
38
-
39
- lastResult(ref) {
40
- return this._results.get(ref) || null;
41
- }
42
-
43
- isHealthy(ref) {
44
- const r = this._results.get(ref);
45
- return Boolean(r && r.ok);
46
- }
47
-
48
- clear(ref) {
49
- if (ref) {
50
- this._results.delete(ref);
51
- this._inProgress.delete(ref);
52
- } else {
53
- this._results.clear();
54
- this._inProgress.clear();
55
- }
56
- }
57
-
58
- snapshot() {
59
- const out = {};
60
- for (const [k, v] of this._results) out[k] = v;
61
- return out;
62
- }
63
- }
package/lib/incident.js DELETED
@@ -1,76 +0,0 @@
1
- // lib/incident.js — auto-create Gitea issue when pool exhausted > threshold.
2
- // ponytail: minimal — caller provides a token + base URL. No retries on rate-limit.
3
-
4
- export const INCIDENT_DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
5
- export const INCIDENT_DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 min between incidents per provider
6
- export const INCIDENT_TIMEOUT_MS = 5000;
7
-
8
- export class IncidentReporter {
9
- constructor({ token, baseUrl, repo, thresholdMs = INCIDENT_DEFAULT_THRESHOLD_MS, cooldownMs = INCIDENT_DEFAULT_COOLDOWN_MS, fetchImpl } = {}) {
10
- if (!token) throw new Error('incident: token required');
11
- if (!baseUrl) throw new Error('incident: baseUrl required');
12
- if (!repo || !repo.includes('/')) throw new Error('incident: repo (owner/name) required');
13
- this._token = token;
14
- this._baseUrl = baseUrl.replace(/\/+$/, '');
15
- this._repo = repo;
16
- this._thresholdMs = thresholdMs;
17
- this._cooldownMs = cooldownMs;
18
- this._lastIncidentAt = new Map(); // provider -> epochMs
19
- this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : () => { throw new Error('incident: no fetch available'); });
20
- }
21
-
22
- // Should we report now? Pure; does not perform I/O.
23
- shouldReport(provider, exhaustedSince, now = Date.now()) {
24
- if (!provider) return false;
25
- if (!Number.isFinite(exhaustedSince)) return false;
26
- if (now - exhaustedSince < this._thresholdMs) return false;
27
- const last = this._lastIncidentAt.get(provider);
28
- if (Number.isFinite(last) && now - last < this._cooldownMs) return false;
29
- return true;
30
- }
31
-
32
- markReported(provider, at = Date.now()) {
33
- this._lastIncidentAt.set(provider, at);
34
- }
35
-
36
- resetCooldown(provider) {
37
- if (provider) this._lastIncidentAt.delete(provider);
38
- else this._lastIncidentAt.clear();
39
- }
40
-
41
- // Open a Gitea issue. ponytail: minimal payload, ignore failures.
42
- async open(provider, exhaustedSince, now = Date.now()) {
43
- if (!this.shouldReport(provider, exhaustedSince, now)) return { reported: false };
44
- const url = `${this._baseUrl}/api/v1/repos/${this._repo}/issues`;
45
- const body = {
46
- title: `prod-incident: pool ${provider} exhausted since ${new Date(exhaustedSince).toISOString()}`,
47
- body: [
48
- 'Auto-generated by `dsh-key-rotation`.',
49
- '',
50
- `- provider: \`${provider}\``,
51
- `- exhaustedSince: \`${new Date(exhaustedSince).toISOString()}\``,
52
- '',
53
- 'All keys in the pool are in cooldown or missing. Check OpenCode provider status and rotate keys.',
54
- ].join('\n'),
55
- labels: ['prod-incident'],
56
- };
57
- const ctrl = new AbortController();
58
- const timer = setTimeout(() => ctrl.abort(), INCIDENT_TIMEOUT_MS);
59
- try {
60
- const res = await this._fetch(url, {
61
- method: 'POST',
62
- headers: { authorization: `token ${this._token}`, 'content-type': 'application/json' },
63
- body: JSON.stringify(body),
64
- signal: ctrl.signal,
65
- });
66
- if (!res.ok) return { reported: false, status: res.status };
67
- const data = await res.json();
68
- this.markReported(provider, now);
69
- return { reported: true, number: data.number, url: data.html_url };
70
- } catch (_) {
71
- return { reported: false };
72
- } finally {
73
- clearTimeout(timer);
74
- }
75
- }
76
- }
@@ -1,64 +0,0 @@
1
- // lib/maintenance.js - pure helpers for #207 (expiry pre-warning) and
2
- // #208 (cost budget). No I/O; the timers in index.js call these and decide
3
- // whether to send webhooks.
4
-
5
- const DAY_MS = 86400000;
6
-
7
- /**
8
- * #207: keys of `pool` whose expiresAt falls within the next `warnDays`.
9
- * Returns [{ ref, expiresInDays, expiresAt }], soonest first.
10
- */
11
- export function expiringSoon(pool, warnDays, now = Date.now()) {
12
- if (!pool || !pool.expiresAt) return [];
13
- const horizon = now + Math.max(1, warnDays ?? 7) * DAY_MS;
14
- return Object.entries(pool.expiresAt)
15
- .filter(([, at]) => at > now && at <= horizon)
16
- .map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
17
- .sort((a, b) => a.expiresAt - b.expiresAt);
18
- }
19
-
20
- /** #207 dedupe: true when a day-level notification for `key` is due. */
21
- export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
22
- if (!lastNotified.has(key)) {
23
- lastNotified.set(key, now);
24
- return true;
25
- }
26
- const last = lastNotified.get(key);
27
- if (now - last < DAY_MS) return false;
28
- lastNotified.set(key, now);
29
- return true;
30
- }
31
-
32
- /**
33
- * #208: total spend of a pool on ISO day `day` (defaults to today)
34
- * across costDays Map<ref, Map<day, cost>>.
35
- */
36
- export function costForDay(costDays, day) {
37
- const d = day ?? new Date().toISOString().slice(0, 10);
38
- let total = 0;
39
- for (const perRef of (costDays?.values() ?? [])) {
40
- total += perRef.get(d) ?? 0;
41
- }
42
- return total;
43
- }
44
-
45
- /** #208: total spend over the last 7 ISO days ending today. */
46
- export function costForWeek(costDays, now = Date.now()) {
47
- let total = 0;
48
- for (let i = 0; i < 7; i++) {
49
- const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
50
- total += costForDay(costDays, d);
51
- }
52
- return total;
53
- }
54
-
55
- /**
56
- * #208 budget verdict for a pool: { spend, budget, ratio, warn, exceeded }.
57
- * budget <= 0 -> never warn.
58
- */
59
- export function budgetVerdict(spend, budget) {
60
- if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
61
- const ratio = spend / budget;
62
- // warn from 80%, exceeded at 100%
63
- return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
64
- }
package/lib/region.js DELETED
@@ -1,50 +0,0 @@
1
- // lib/region.js — region tag + failover helper.
2
- // ponytail: simple — providers declare an optional 'region' (string).
3
- // When the primary provider hits exhaustion AND a same-region fallback is
4
- // configured, the plugin picks that as next fallback.
5
-
6
- export const REGION_NONE = '';
7
- export const REGION_GLOBAL = 'global';
8
-
9
- export class RegionMap {
10
- constructor() {
11
- this._byProvider = new Map(); // provider id -> region
12
- }
13
-
14
- set(provider, region = REGION_GLOBAL) {
15
- if (!provider) return;
16
- if (!region) region = REGION_GLOBAL;
17
- this._byProvider.set(provider, region);
18
- }
19
-
20
- get(provider) {
21
- return this._byProvider.get(provider) || REGION_GLOBAL;
22
- }
23
-
24
- // Pick a fallback for `provider`. Returns another provider in the same
25
- // region if available; otherwise null. Returns null for unknown providers
26
- // (we don't know their region -> conservative).
27
- pickFallback(provider) {
28
- if (!this._byProvider.has(provider)) return null;
29
- const region = this.get(provider);
30
- for (const [p, r] of this._byProvider) {
31
- if (p === provider) continue;
32
- if (r === region) return p;
33
- }
34
- return null;
35
- }
36
-
37
- snapshot() {
38
- const out = {};
39
- for (const [k, v] of this._byProvider) out[k] = v;
40
- return out;
41
- }
42
-
43
- clear() {
44
- this._byProvider.clear();
45
- }
46
-
47
- get size() {
48
- return this._byProvider.size;
49
- }
50
- }
package/lib/shadow.js DELETED
@@ -1,81 +0,0 @@
1
- // lib/shadow.js — shadow A/B traffic sampling.
2
- // ponytail: per-provider counter, simple percent gating.
3
-
4
- export const SHADOW_DEFAULT_PERCENT = 0; // 0 = disabled
5
- export const SHADOW_BUCKET = 100; // percent base
6
-
7
- export class ShadowRouter {
8
- constructor({ primary, secondary, percent = SHADOW_DEFAULT_PERCENT } = {}) {
9
- this._primary = primary || '';
10
- this._secondary = secondary || '';
11
- this._percent = Number.isFinite(percent) && percent > 0 ? Math.min(SHADOW_BUCKET, Math.floor(percent)) : 0;
12
- this._sent = 0;
13
- this._shadowed = 0;
14
- this._latencySumPrimary = 0;
15
- this._latencySumSecondary = 0;
16
- this._latencyCountPrimary = 0;
17
- this._latencyCountSecondary = 0;
18
- }
19
-
20
- isEnabled() {
21
- return this._percent > 0 && Boolean(this._primary) && Boolean(this._secondary) && this._primary !== this._secondary;
22
- }
23
-
24
- pick(requestHash = Math.random()) {
25
- if (!this.isEnabled()) return { primary: this._primary, secondary: null, sampled: false };
26
- // Convert requestHash to [0, SHADOW_BUCKET)
27
- let h;
28
- if (typeof requestHash === 'number') {
29
- h = Math.floor(requestHash * SHADOW_BUCKET);
30
- } else {
31
- // Stable hash: fnv1a-lite on string
32
- let str = String(requestHash);
33
- let x = 2166136261;
34
- for (let i = 0; i < str.length; i++) {
35
- x ^= str.charCodeAt(i);
36
- x = (x * 16777619) >>> 0;
37
- }
38
- h = x % SHADOW_BUCKET;
39
- }
40
- const sampled = h < this._percent;
41
- this._sent += 1;
42
- if (sampled) this._shadowed += 1;
43
- return { primary: this._primary, secondary: sampled ? this._secondary : null, sampled };
44
- }
45
-
46
- recordLatency(target, ms) {
47
- if (!Number.isFinite(ms) || ms < 0) return;
48
- if (target === this._primary) {
49
- this._latencySumPrimary += ms;
50
- this._latencyCountPrimary += 1;
51
- } else if (target === this._secondary) {
52
- this._latencySumSecondary += ms;
53
- this._latencyCountSecondary += 1;
54
- }
55
- }
56
-
57
- snapshot() {
58
- const avg = (sum, count) => (count > 0 ? sum / count : null);
59
- return {
60
- primary: this._primary,
61
- secondary: this._secondary,
62
- percent: this._percent,
63
- enabled: this.isEnabled(),
64
- sent: this._sent,
65
- shadowed: this._shadowed,
66
- avgLatencyMs: {
67
- primary: avg(this._latencySumPrimary, this._latencyCountPrimary),
68
- secondary: avg(this._latencySumSecondary, this._latencyCountSecondary),
69
- },
70
- };
71
- }
72
-
73
- reset() {
74
- this._sent = 0;
75
- this._shadowed = 0;
76
- this._latencySumPrimary = 0;
77
- this._latencySumSecondary = 0;
78
- this._latencyCountPrimary = 0;
79
- this._latencyCountSecondary = 0;
80
- }
81
- }