@goodandready/dsh-key-rotation 0.8.11 → 0.8.13

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
@@ -3,33 +3,17 @@
3
3
  // Transparent key pool rotation: credentials.resolve patch + llm/stream
4
4
  // interceptor. Provider identity never changes (keeps pi-ai replay state).
5
5
  // See README.md and docs/design/DESIGN.md for the full contract.
6
- // Config (all optional): switchCodes, cooldownMs, providers[{provider,keys}],
7
- // verboseLogging (default false) gates per-request rotation logs.
8
6
 
9
7
  import { AsyncLocalStorage } from 'node:async_hooks';
10
8
  import Schema from '@deepseek-ai/schemastery';
11
- 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';
12
-
13
- export const name = 'dsh-key-rotation';
14
- export const inject = ['llm', 'webServer', 'settings', 'credentials'];
15
- export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
16
-
17
- /** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
18
- const NS = 'dsh-key-rotation';
19
- /** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
20
- const CONFIG_PATH = '/dsh-key-rotation/config';
21
- const STATUS_PATH = '/dsh-key-rotation/status';
22
- const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
23
- const KEY_PATH = '/dsh-key-rotation/key';
24
- const RESET_PATH = '/dsh-key-rotation/reset';
25
- const IMPORT_PATH = '/dsh-key-rotation/import';
26
- const HEALTH_PATH = '/dsh-key-rotation/health';
27
- const USAGE_PATH = '/dsh-key-rotation/usage';
28
- const TEST_PATH = '/dsh-key-rotation/test';
29
- const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
30
- import { sortAttemptList } from './pool.js';
31
- import { LastTestCache, SandboxRunner } from './sandbox.js';
32
- import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
9
+ import {
10
+ keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN,
11
+ DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
12
+ recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
13
+ computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
14
+ formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek,
15
+ budgetVerdict, sortAttemptList,
16
+ } from './pool.js';
33
17
  import { LatencyHistogram } from './histogram.js';
34
18
  import { pickCascadeFallback } from './cascade.js';
35
19
  import { ConcurrencyTracker } from './concurrency.js';
@@ -38,8 +22,6 @@ import { QuotaStore } from './quota.js';
38
22
  import { WebhookSender } from './webhook.js';
39
23
  import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
40
24
  import { usageRows, usageCsv, compactUsage } from './usage-report.js';
41
-
42
- const dispatchStorage = new AsyncLocalStorage();
43
25
  import { findSecrets, looksLikeApiSecret } from './keycheck.js';
44
26
  import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
45
27
  import { createRotate } from './rotate.js';
@@ -50,135 +32,49 @@ import { BoundedMap } from './bounded-map.js';
50
32
  import { classifyFailure } from './error-taxonomy.js';
51
33
  import { safeParseJson } from './atomic-io.js';
52
34
  import { registerOpsRoutes } from './routes-ops.js';
53
- import { StatePersistence, resolveStatePath } from './persistence.js';
54
- import path from 'node:path';
55
- import { registerPluginUpdater } from './plugin-updater.js'
35
+ import { registerPluginUpdater } from './plugin-updater.js';
36
+ import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
37
+ import { getLogger } from './logger.js';
38
+ import { createSandboxService } from './sandbox-service.js';
39
+ import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
40
+ import { buildPoolItem, cleanupRemovedProviders } from './pool-builder.js';
41
+ import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
42
+
43
+ export const name = 'dsh-key-rotation';
44
+ export const inject = ['llm', 'webServer', 'settings', 'credentials'];
45
+ export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
46
+ export { notifySwitch, notifyExhaustion };
56
47
 
57
- /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
48
+ const NS = 'dsh-key-rotation';
49
+ const CONFIG_PATH = '/dsh-key-rotation/config';
58
50
  const PIAI_NS = 'llm-pi-ai';
59
- /** Marker on internally re-dispatched requests so the interceptor does not loop. */
60
51
  const MARKER = '__dshKeyRotation';
61
- /** #199: set true via webhook action; checked in the llm/stream interceptor. */
52
+
62
53
  let rotationDisabled = false;
63
- // #207/#208 dedupe maps: one notification per key/window per day.
64
54
  const expiryNotifiedAt = new Map();
65
55
  const budgetNotifiedAt = new Map();
66
- const switchNotifiedAt = new Map();
67
56
  const lowHealthNotifiedAt = new Map();
68
57
  const sloNotifiedAt = new Map();
69
- const DAY_MS = 86400000;
70
-
71
- // #216: one webhook per switch, deduped to at most one message per provider
72
- // per switchNotifyThrottleMs. Extracted for testability.
73
- export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
74
- if (!runtime?.notifyWebhook) return;
75
- const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
76
- const last = switchNotifiedAt.get(info.provider) ?? 0;
77
- const now = hooks.now();
78
- if (now - last < throttle) return;
79
- switchNotifiedAt.set(info.provider, now);
80
- // #263: non-blocking — enqueue never awaits webhook I/O
81
- const send = hooks.notifyQueue
82
- ? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
83
- : (url, payload) => hooks.webhookSender.send(url, payload);
84
- send(runtime.notifyWebhook, {
85
- title: `Key switched: ${info.provider}`,
86
- text: `${info.from} failed (${info.code}) - next key in pool`,
87
- provider: info.provider,
88
- kind: 'switch',
89
- from: info.from,
90
- code: info.code,
91
- at: info.at,
92
- });
93
- }
94
- const MAX_EVENTS = 50;
95
- function pushEvent(pool, ref, reason, cooldownMs, type) {
96
- const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
97
- pool.state.events.push(ev);
98
- if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
99
- }
100
-
101
58
 
102
- // Fallback classification by failure message. pi-ai surfaces many real quota /
103
- // rate-limit / transport failures as thrown exceptions (e.g. the OpenAI SDK
104
- // throws on HTTP 429 before the stream starts), and dsh-llm then normalizes
105
- // them to finish chunks with code "UNKNOWN". The message still carries the
106
- // provider's own text ("429: ...", "Weekly usage limit reached", ...), so we
107
- // treat pre-content failures whose message matches these patterns as
108
- // switchable even when the code is not in `switchCodes`.
109
-
110
- // Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
111
- let lastTestCacheRunnerCtx = null;
112
- const lastTestCache = new LastTestCache();
59
+ const dispatchStorage = new AsyncLocalStorage();
113
60
  const latencyHistogram = new LatencyHistogram();
114
61
  const quotaStore = new QuotaStore();
115
- // #260/#263 module-scope infra (process-wide, reset on reload via buildRuntime)
116
- let moduleBreaker = null;
117
- let moduleNotifyQueue = null;
118
- // Global config accessor safe against early initialization
62
+ const concurrencyTracker = new ConcurrencyTracker();
63
+
64
+ let moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
65
+ const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
66
+ let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
67
+
119
68
  let getConfig = () => null;
120
69
  function verboseLoggingOn() {
121
70
  try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
122
71
  }
72
+
123
73
  let getRuntime = () => null;
124
- let sandboxRunner = null;
125
- const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
126
- // #263: queue webhook I/O off the hot path
127
- moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
128
- // #260: process-wide breaker; thresholds re-read from runtime when dispatching
129
- moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
130
- const concurrencyTracker = new ConcurrencyTracker();
131
- function ensureSandboxRunner(ctx) {
132
- if (sandboxRunner) return sandboxRunner;
133
- // provider id or key ref -> baseUrl (stripped of trailing /) for fetch /models probe
134
- function resolveBaseUrl(providerOrRef) {
135
- try {
136
- let provider = providerOrRef;
137
- const rt = typeof getRuntime === 'function' ? getRuntime() : null;
138
- const pool = rt?.poolByRef?.get(providerOrRef);
139
- if (pool?.base) provider = pool.base;
140
- else if (pool?.provider) provider = pool.provider;
141
-
142
- const c = ctx || lastTestCacheRunnerCtx;
143
- const pInfo = c?.llm?.getProvider?.(provider);
144
- if (pInfo && (pInfo.baseUrl || pInfo.endpoint || pInfo.url)) {
145
- return String(pInfo.baseUrl || pInfo.endpoint || pInfo.url);
146
- }
147
- for (const info of (c?.llm?.listProviders?.() || [])) {
148
- if (info && (info.id === provider || info.name === provider)) {
149
- const u = info.baseUrl || info.endpoint || info.url;
150
- if (u) return String(u);
151
- }
152
- }
153
- const ns = c?.get ? c.get(PIAI_NS) : null;
154
- const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
155
- if (Array.isArray(list)) {
156
- const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
157
- const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
158
- if (base) return String(base);
159
- }
160
- return null;
161
- } catch (_) {
162
- return null;
163
- }
164
- }
165
- sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
166
- return sandboxRunner;
167
- }
168
- async function probeRef(ref, key) {
169
- // ref may be like "PROVIDER/KEY_NAME" — for sandbox we only care about the credential ref
170
- // (the resolveBaseUrl uses the full provider id; ref can carry any string)
171
- const runner = ensureSandboxRunner(lastTestCacheRunnerCtx);
172
- const result = await runner.probeModels(ref, key);
173
- lastTestCache.set(ref, { ...result, at: Date.now() });
174
- return result;
175
- }
176
74
 
177
- // // Bootstrap key pools. The user configures them in the Settings GUI or via
178
- // the dsh profile bundle config; the plugin itself ships no provider defaults
179
- // so it does not bind to any specific installation. Empty array means: until
180
- // the user adds a pool, no rotation happens, and every provider falls back to
181
- // its single configured credential exactly as before this plugin was installed.
75
+ const sandboxService = createSandboxService({ getRuntime: () => getRuntime() });
76
+ const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
77
+
182
78
  const DEFAULT_PROVIDERS = [];
183
79
 
184
80
  export const Config = Schema.object({
@@ -209,12 +105,10 @@ export const Config = Schema.object({
209
105
  expiryWarnDays: Schema.number().default(7),
210
106
  switchNotify: Schema.boolean().default(false),
211
107
  verboseLogging: Schema.boolean().default(false),
212
- // #260 circuit breaker
213
108
  circuitBreakerEnabled: Schema.boolean().default(true),
214
109
  circuitBreakerThreshold: Schema.number().default(5),
215
110
  circuitBreakerOpenMs: Schema.number().default(30000),
216
111
  circuitBreakerHalfOpenProbes: Schema.number().default(1),
217
- // #287 persistence across restarts
218
112
  persistenceEnabled: Schema.boolean().default(true),
219
113
  persistencePath: Schema.string().default(''),
220
114
  switchNotifyThrottleMs: Schema.number().default(60000),
@@ -240,163 +134,44 @@ export const Config = Schema.object({
240
134
  })).default([...DEFAULT_PROVIDERS]),
241
135
  });
242
136
 
243
- // ── config bridge (GET/PUT/DELETE on CONFIG_PATH), mirroring llm-fallback ──
244
-
245
-
246
137
  function registerConfigBridge(ctx, getCloneIds) {
247
138
  return ctx.webServer.register({
248
139
  kind: 'exact',
249
140
  path: CONFIG_PATH,
250
- handler: (req, res) => void handleConfigBridge(ctx, req, res, getCloneIds),
141
+ handler: (req, res) => {
142
+ if (!['GET', 'PUT', 'DELETE', 'OPTIONS'].includes(req.method)) {
143
+ res.writeHead(405, { 'Content-Type': 'application/json' });
144
+ res.end(JSON.stringify({ error: 'Method Not Allowed' }));
145
+ return;
146
+ }
147
+ handleConfigBridge(ctx, req, res, getCloneIds);
148
+ },
251
149
  });
252
150
  }
253
151
 
254
- // ── plugin ──
255
-
256
152
  export function apply(ctx, config = {}) {
257
- // GUI section: defaults -> cordis row config -> saved user section.
258
- // (installSettingsSection inlined: no @deepseek-ai/dsh-settings import, so the
259
- // profile does not need a second copy of that package.)
153
+ const logger = getLogger(ctx);
260
154
  getConfig = () => config;
261
155
  registerConfigBridge(ctx, () => buildRuntime().cloneIds);
262
- lastTestCacheRunnerCtx = ctx;
263
- // Cache should not survive profile restarts (apply is called per reload).
264
- // We deliberately do NOT clear on every apply — that would wipe badges when
265
- // the user is just typing in the settings card. Re-init only on true reload.
266
156
  ensureSandboxRunner(ctx);
267
157
 
268
- // Self-healing idle cooldowns: every 60s, lift expired cooldowns for keys
269
- // that have been idle for selfHealIdleMs (default 1h). ponytail: small
270
- // interval, low cost; skipped when selfHealCooldown is disabled in config.
271
- // ponytail: keep handle on the same ctx via closure so buildRuntime() reads
272
- // fresh config on every tick. Naive but correct: 60s cadence is cheap.
273
- // Self-healing idle cooldowns lifecycle effect
274
- ctx.effect(() => {
275
- const cfg = getConfig();
276
- if (!cfg || cfg.selfHealCooldown === false) return () => {};
277
- const timer = setInterval(() => {
278
- try {
279
- const c = getConfig();
280
- if (!c || c.selfHealCooldown === false) return;
281
- const idle = Number.isFinite(c.selfHealIdleMs) && c.selfHealIdleMs > 0 ? c.selfHealIdleMs : 3600000;
282
- const providers = Array.isArray(c.providers) ? c.providers : [];
283
- const pools = providers
284
- .map((p) => buildRuntime().providerToPool.get(p.provider))
285
- .filter(Boolean);
286
- healIdleCooldowns(pools, idle);
287
- } catch (_) { /* ponytail: never crash the timer */ }
288
- }, 60000);
289
- if (typeof timer.unref === 'function') timer.unref();
290
- return () => clearInterval(timer);
291
- }, 'dsh-key-rotation: self-healing idle');
292
-
293
- // ── key-pool state, persisted across config reloads ──
294
- // base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
295
- const poolState = new Map();
158
+ setupIdleHealEffect(ctx, getConfig, buildRuntime);
296
159
 
297
- // #287: persist cooldowns / circuit / quota across DSH reloads.
298
- // Path resolution: explicit config -> host data dir -> DSH_HOME -> cwd.
299
- // Disabled (with a single warn) when no writable directory can be found.
300
- let statePersistence = null;
301
- try {
302
- const cfg0 = getConfig() ?? config ?? {};
303
- // Cordis ctx properties require inject; bare getters throw.
304
- // Use only host env/cwd so apply() never trips the injector.
305
- const hostDirs = [
306
- process.env.DSH_HOME,
307
- process.cwd(),
308
- ].filter((d) => typeof d === 'string' && d.length > 0);
309
- const resolvedPath = resolveStatePath({
310
- configuredPath: cfg0.persistencePath,
311
- dataDir: hostDirs[0],
312
- });
313
- if (cfg0.persistenceEnabled !== false && resolvedPath) {
314
- statePersistence = new StatePersistence({ filePath: resolvedPath });
315
- statePersistence.load().then((snap) => {
316
- if (!snap) return;
317
- try {
318
- StatePersistence.restorePools(poolState, snap);
319
- if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
320
- if (verboseLoggingOn()) {
321
- console.warn(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
322
- }
323
- } catch (e) {
324
- console.warn('[dsh-key-rotation] persistence restore failed', e?.message ?? e);
325
- }
326
- }).catch(() => {});
327
- } else if (cfg0.persistenceEnabled !== false && !resolvedPath) {
328
- console.warn('[dsh-key-rotation] persistence disabled: no data directory');
329
- }
330
- } catch (e) {
331
- console.warn('[dsh-key-rotation] persistence init failed', e?.message ?? e);
332
- }
333
-
334
- function persistenceSnapshot() {
335
- if (!statePersistence) return null;
336
- return StatePersistence.serialize({
337
- poolState,
338
- circuitSnapshot: moduleBreaker ? moduleBreaker.snapshot() : {},
339
- quotaSnapshot: {},
340
- });
341
- }
342
-
343
- function schedulePersist() {
344
- if (!statePersistence) return;
345
- const snap = persistenceSnapshot();
346
- if (snap) statePersistence.save(snap);
347
- }
160
+ const poolState = new Map();
161
+ const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
162
+ cfg0: getConfig() ?? config ?? {},
163
+ poolState,
164
+ moduleBreaker,
165
+ verboseLoggingOn,
166
+ logger,
167
+ });
348
168
 
349
- // Auto-healing / auto-unbreak for broken keys (#303)
350
- ctx.effect(() => {
351
- const cfg = getConfig();
352
- const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
353
- if (!intervalMin || intervalMin <= 0) return () => {};
354
- const intervalMs = intervalMin * 60 * 1000;
355
- const timer = setInterval(async () => {
356
- try {
357
- const c = getConfig();
358
- if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
359
- const { pools } = buildRuntime();
360
- const runner = ensureSandboxRunner(ctx);
361
- await autoUnbreakBrokenKeys(pools, async (ref) => {
362
- let val = (await ctx.credentials?.resolve?.(ref))?.value;
363
- if (!val) return { ok: false };
364
- return runner.probeModels(ref, val);
365
- });
366
- } catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
367
- }, intervalMs);
368
- if (typeof timer.unref === 'function') timer.unref();
369
- return () => clearInterval(timer);
370
- }, 'dsh-key-rotation: auto-unbreak');
169
+ setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
371
170
 
372
- ctx.effect(() => {
373
- const timer = setInterval(() => {
374
- try { schedulePersist(); }
375
- catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
376
- }, 15000);
377
- if (typeof timer.unref === 'function') timer.unref();
378
- return () => {
379
- clearInterval(timer);
380
- try {
381
- if (statePersistence) {
382
- const snap = persistenceSnapshot();
383
- if (snap) {
384
- statePersistence.save(snap);
385
- // best-effort flush; dispose clears the debounce timer
386
- void statePersistence.flush();
387
- }
388
- statePersistence.dispose();
389
- }
390
- } catch (e) {
391
- ctx.logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
392
- }
393
- };
394
- }, 'dsh-key-rotation: state persistence');
395
- // Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
171
+ // Periodic sweep of expired cooldowns
396
172
  ctx.effect(() => {
397
173
  const id = setInterval(() => {
398
174
  const now = Date.now();
399
- // probe events for keys whose cooldown just expired
400
175
  for (const st of poolState.values()) {
401
176
  for (const [ref, until] of [...(st.failedUntil?.entries() ?? [])]) {
402
177
  if (until <= now && !st.probedAt?.has(ref)) {
@@ -410,160 +185,60 @@ export function apply(ctx, config = {}) {
410
185
  const runtime = buildRuntime();
411
186
  const allActiveRefs = Array.from(runtime.poolByRef.keys());
412
187
  const n = sweepExpired(poolState, now, allActiveRefs);
413
- if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
188
+ if (n > 0) logger.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
414
189
  for (const pool of runtime.poolByRef.values()) {
415
190
  compactUsage(pool, 30, now);
416
191
  }
417
- // #207 expiry pre-warning + #208 cost budget - piggybacked on this timer,
418
- // deduped to one notification per key/window per day (shouldNotifyDaily).
419
- try {
420
- const seen = new Set();
421
- for (const pool of runtime.poolByRef.values()) {
422
- if (seen.has(pool.base)) continue;
423
- seen.add(pool.base);
424
- // #207: keys expiring within expiryWarnDays -> one webhook per key/day
425
- for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
426
- if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
427
- console.warn(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
428
- if (runtime.notifyWebhook) {
429
- webhookSender.send(runtime.notifyWebhook, {
430
- title: `Key expiring soon: ${pool.base}`,
431
- text: `${ref} expires in ~${expiresInDays} day(s)`,
432
- provider: pool.base,
433
- kind: 'expiry',
434
- keys: [ref],
435
- });
436
- }
437
- }
438
- // #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
439
- const budget = runtime.providerBudgets.get(pool.base);
440
- if (!budget) continue;
441
- const daily = costForDay(pool.state.costDays);
442
- const weekly = costForWeek(pool.state.costDays, now);
443
- const verdict = budgetVerdict(daily, budget.costBudgetDaily);
444
- const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
445
- const hit = verdict.warn || wVerdict.warn;
446
- if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
447
- console.warn(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
448
- if (runtime.notifyWebhook) {
449
- // #217: budget webhook gains action buttons when a callback token
450
- // is configured (the /webhook-action route already knows these ids)
451
- const token = runtime.webhookActionToken ?? '';
452
- webhookSender.send(runtime.notifyWebhook, {
453
- title: `Cost budget: ${pool.base}`,
454
- text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
455
- provider: pool.base,
456
- kind: 'budget',
457
- spend: { daily, weekly },
458
- actionToken: token || undefined,
459
- actions: token ? [
460
- { id: `pause-${pool.base}`, label: 'Pause 1h' },
461
- { id: `reset-${pool.base}`, label: 'Reset cooldown' },
462
- ] : undefined,
463
- });
464
- }
465
- }
466
- if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
467
- const until = now + DAY_MS;
468
- for (const ref of pool.refs) {
469
- if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
470
- }
471
- }
472
- // #221: pool running low - webhook while healthy < warnBelowHealthy
473
- const warnBelow = runtime.warnBelowHealthy ?? 0;
474
- if (warnBelow > 0) {
475
- let healthy = 0;
476
- for (const ref of pool.refs) {
477
- const fu = pool.state.failedUntil.get(ref);
478
- if (fu !== undefined && fu > now) continue;
479
- const exp = pool.expiresAt?.[ref];
480
- if (exp !== undefined && now >= exp) continue;
481
- healthy++;
482
- }
483
- if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
484
- console.warn(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
485
- if (runtime.notifyWebhook) {
486
- const token = runtime.webhookActionToken ?? '';
487
- webhookSender.send(runtime.notifyWebhook, {
488
- title: `Pool running low: ${pool.base}`,
489
- text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
490
- provider: pool.base,
491
- kind: 'low-health',
492
- healthy,
493
- total: pool.refs.length,
494
- actionToken: token || undefined,
495
- actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
496
- });
497
- }
498
- }
499
- }
500
- // #225: latency SLO - webhook when a key's p95 exceeds the threshold
501
- const slo = runtime.latencySloMs ?? 0;
502
- if (slo > 0) {
503
- for (const ref of pool.refs) {
504
- const snap = latencyHistogram.snapshot(ref);
505
- if (!snap.p95 || snap.p95 <= slo) continue;
506
- if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
507
- console.warn(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
508
- if (runtime.notifyWebhook) {
509
- webhookSender.send(runtime.notifyWebhook, {
510
- title: `Latency SLO exceeded: ${pool.base}`,
511
- text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
512
- provider: pool.base,
513
- kind: 'latency-slo',
514
- ref,
515
- p95: Math.round(snap.p95),
516
- slo,
517
- });
518
- }
519
- }
520
- }
521
- }
522
- } catch (_) { /* maintenance must never crash the sweep */ }
192
+ checkBudgetAndHealthAlerts({
193
+ runtime,
194
+ poolState,
195
+ now,
196
+ expiryNotifiedAt,
197
+ budgetNotifiedAt,
198
+ lowHealthNotifiedAt,
199
+ sloNotifiedAt,
200
+ latencyHistogram,
201
+ webhookSender,
202
+ logger,
203
+ });
523
204
  }, 30000);
524
205
  if (typeof id.unref === 'function') id.unref();
525
206
  return () => clearInterval(id);
526
207
  }, 'dsh-key-rotation: sweep expired cooldowns');
527
208
 
528
- // ── runtime snapshot: config + llm-pi-ai profile mapping ──
529
209
  let cachedRuntime = null;
530
210
  let lastConfigRef = null;
531
211
  let lastProfilesRef = null;
532
212
 
533
- getRuntime = buildRuntime;
534
213
  function buildRuntime() {
535
- const rawConfig = getConfig() ?? {};
536
- let currentProfiles = null;
214
+ const cfg = getConfig() ?? {};
215
+ let profilesSnapshot = null;
537
216
  try {
538
- currentProfiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
217
+ profilesSnapshot = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
539
218
  } catch {
540
- /* settings not mounted yet — empty mapping */
219
+ profilesSnapshot = null;
541
220
  }
542
-
543
- if (cachedRuntime && lastConfigRef === rawConfig && lastProfilesRef === currentProfiles) {
221
+ if (cachedRuntime && cfg === lastConfigRef && profilesSnapshot === lastProfilesRef) {
544
222
  return cachedRuntime;
545
223
  }
224
+ lastConfigRef = cfg;
225
+ lastProfilesRef = profilesSnapshot;
546
226
 
547
- const cfg = Config(rawConfig) ?? {};
548
- const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
227
+ const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
549
228
  const cooldownMs = cfg.cooldownMs ?? 60000;
550
- const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
229
+ const maxCooldownMs = cfg.maxCooldownMs;
551
230
  const notifyWebhook = cfg.notifyWebhook ?? '';
552
231
  const notifyThreshold = cfg.notifyThreshold ?? 3;
232
+ const concurrencyLimit = cfg.concurrencyLimit ?? 0;
233
+ const cascade = cfg.cascade ?? [];
234
+ const quotaResetWindow = cfg.quotaResetWindow ?? { type: 'midnight_utc', hour: 0 };
553
235
  const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
554
236
  const rpmLimit = cfg.rpmLimit ?? 0;
555
237
  const webhookActionToken = cfg.webhookActionToken ?? '';
556
- const concurrencyLimit = cfg.concurrencyLimit ?? 0;
557
- const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
558
- const quotaResetWindow = cfg.quotaResetWindow || null;
559
238
 
560
- // ref -> pool (every key env of every configured provider)
561
239
  const poolByRef = new Map();
562
- // provider route (from llm-pi-ai profiles) -> its key pool
563
240
  const providerToPool = new Map();
564
- // per-model key pools: provider -> Map<model, pool>
565
241
  const modelPoolByProvider = new Map();
566
- // clone route ids (for the settings dropdown filter)
567
242
  const cloneIds = new Set();
568
243
 
569
244
  const makeState = (base) => {
@@ -580,60 +255,31 @@ export function apply(ctx, config = {}) {
580
255
  byModel: new Map(),
581
256
  usageDays: new Map(),
582
257
  quotaWindows: new Map(),
583
- // #260 per-provider circuit breaker (lazy)
584
- breaker: null,
585
- pointer: 0,
586
- lastUsed: undefined,
587
- switches: 0,
588
- lastReason: undefined,
589
- lastSwitchAt: undefined,
590
- lastExhaustionAt: undefined,
591
- exhaustionCount: 0,
258
+ costDays: new Map(),
592
259
  events: [],
260
+ pointer: 0,
261
+ lastUsed: null,
593
262
  };
594
263
  poolState.set(base, st);
595
264
  }
596
265
  return st;
597
266
  };
598
- const parseExpiry = (v) => {
599
- if (typeof v === 'number' && v > 0) return v;
600
- if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
601
- return undefined;
602
- };
603
- const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) => {
604
- const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
605
- if (refs.length === 0) return null;
606
- const w = Array.isArray(weights) ? weights : [];
607
- const weightedRefs = [];
608
- for (let i = 0; i < refs.length; i++) {
609
- const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
610
- for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
611
- }
612
- const parsedExpiry = {};
613
- if (Array.isArray(expiresAt)) {
614
- for (let i = 0; i < refs.length; i++) {
615
- const exp = parseExpiry(expiresAt[i]);
616
- if (exp !== undefined) parsedExpiry[refs[i]] = exp;
617
- }
618
- }
619
- return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
620
- weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
621
- state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit, routingStrategy: poolStrategy, proactiveRateLimitGuard: poolGuard };
622
- };
267
+
268
+ const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
269
+ buildPoolItem({ base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
270
+
623
271
  for (const p of cfg.providers ?? []) {
624
272
  const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
625
273
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
626
- // base provider pool (fallback)
627
- const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax);
274
+ const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
628
275
  if (pool) {
629
276
  for (const ref of pool.refs) poolByRef.set(ref, pool);
630
277
  for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
631
278
  }
632
- // per-model pools
633
279
  const models = p.models ?? {};
634
280
  const byModel = new Map();
635
281
  for (const [model, mp] of Object.entries(models)) {
636
- const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax);
282
+ const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
637
283
  if (mpool) {
638
284
  byModel.set(model, mpool);
639
285
  for (const ref of mpool.refs) poolByRef.set(ref, mpool);
@@ -645,30 +291,14 @@ export function apply(ctx, config = {}) {
645
291
  let profiles = {};
646
292
  try {
647
293
  profiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? {};
648
- } catch {
649
- /* settings not mounted yet — empty mapping */
650
- }
294
+ } catch { /* settings not mounted yet */ }
651
295
  for (const [provider, profile] of Object.entries(profiles)) {
652
296
  if (profile?.apiKeyEnv && poolByRef.has(profile.apiKeyEnv)) {
653
297
  providerToPool.set(provider, poolByRef.get(profile.apiKeyEnv));
654
298
  }
655
299
  }
656
300
 
657
- // auto-cleanup: remove poolState for providers that are now empty or removed
658
- for (const key of [...poolState.keys()]) {
659
- if (![...poolByRef.values()].some((p) => p.base === key)) {
660
- poolState.delete(key);
661
- lowHealthNotifiedAt.delete(key);
662
- budgetNotifiedAt.delete(key + ':budget');
663
- }
664
- }
665
- // #192: drop RPM windows for refs that no longer belong to any pool
666
- for (const st of poolState.values()) {
667
- if (st.rpmWindows) bucketSweep(st.rpmWindows, new Set(poolByRef.keys()));
668
- }
669
- // #195: provider -> tags (metadata, surfaced in status)
670
301
  const providerTags = new Map();
671
- // #208: provider -> { costBudgetDaily, costBudgetWeekly, pauseOnBudget }
672
302
  const providerBudgets = new Map();
673
303
  for (const p of cfg.providers ?? []) {
674
304
  if (Array.isArray(p.tags) && p.tags.length > 0) providerTags.set(p.provider, p.tags);
@@ -676,136 +306,137 @@ export function apply(ctx, config = {}) {
676
306
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
677
307
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
678
308
  }
679
- // #266: orphan clone-route GC — expected clones only for live multi-key providers
309
+
680
310
  const expectedClones = new Set();
681
311
  for (const p of cfg.providers ?? []) {
682
312
  const n = (p.keys ?? []).filter((k) => typeof k === 'string' && k.length > 0).length;
683
313
  for (let i = 1; i < n; i++) expectedClones.add(`${p.provider}-${i + 1}`);
684
314
  }
685
- // drop breaker entries for removed providers
686
- if (moduleBreaker) {
687
- for (const key of Object.keys(moduleBreaker.snapshot())) {
688
- if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
689
- // keep until TTL; only reset if provider gone from config entirely
690
- const still = (cfg.providers ?? []).some((p) => p.provider === key);
691
- if (!still) moduleBreaker.reset(key);
692
- }
693
- }
694
- }
695
- cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
315
+
316
+ cleanupRemovedProviders({
317
+ cfg,
318
+ poolState,
319
+ poolByRef,
320
+ providerToPool,
321
+ expectedClones,
322
+ moduleBreaker,
323
+ lowHealthNotifiedAt,
324
+ budgetNotifiedAt,
325
+ });
326
+
327
+ cachedRuntime = {
328
+ switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold,
329
+ concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit,
330
+ webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7,
331
+ switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
696
332
  proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
697
333
  selfHealingIntervalMinutes: cfg.selfHealingIntervalMinutes ?? 30,
698
- routingStrategy: cfg.routingStrategy ?? 'round-robin', switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
334
+ routingStrategy: cfg.routingStrategy ?? 'round-robin',
335
+ switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
336
+ warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
337
+ providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
338
+ cloneIds, expectedClones,
699
339
  circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
700
340
  circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
701
341
  circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
702
342
  circuitBreakerHalfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
703
- breaker: moduleBreaker,
704
- notifyQueue: moduleNotifyQueue,
343
+ pools: [...new Set(poolByRef.values())],
705
344
  };
706
- lastConfigRef = rawConfig;
707
- lastProfilesRef = currentProfiles;
708
345
  return cachedRuntime;
709
346
  }
710
347
 
711
- // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
712
- // Round-robin over the pool, skipping keys in cooldown; the request's
713
- // provider identity never changes, so pi-ai replay state stays consistent.
348
+ getRuntime = () => buildRuntime();
349
+
714
350
  ctx.effect(() => {
715
351
  const credentials = ctx.get('credentials');
716
- if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
352
+ if (credentials && typeof credentials.resolve === 'function') {
717
353
  const original = credentials.resolve.bind(credentials);
718
- // Kept for the status route: it must ask about one exact ref instead of
719
- // being rotated to a different key by the patch below.
720
354
  credentials.__dshKeyRotationOriginalResolve = original;
721
355
  credentials.resolve = async (ref) => {
722
- const { poolByRef } = buildRuntime();
723
- const pool = poolByRef.get(ref);
724
- if (!pool) return original(ref);
725
- const now = Date.now();
726
- const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
727
- let list = pool.weightedRefs ?? pool.refs;
728
- if (strat === 'lowest-latency' || strat === 'least-loaded') {
729
- list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
730
- }
731
- const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
732
- for (let i = 0; i < list.length; i++) {
733
- const index = (start + i) % list.length;
734
- const candidate = list[index];
735
- const until = pool.state.failedUntil.get(candidate);
736
- if (until !== undefined && until > now) continue;
737
- if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
738
- // #192 RPM token bucket: skip a key that already hit its requests/min cap
739
- const rpmLimit = pool.rpmLimit ?? 0;
740
- if (rpmLimit > 0) {
741
- if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
742
- if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now)) {
743
- const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, now);
744
- if ((pool.state.failedUntil.get(candidate) ?? 0) < now + waitMs) {
745
- pool.state.failedUntil.set(candidate, now + waitMs);
746
- }
747
- continue;
748
- }
356
+ const { poolByRef } = buildRuntime();
357
+ const pool = poolByRef.get(ref);
358
+ if (!pool) return original(ref);
359
+ const now = Date.now();
360
+ const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
361
+ let list = pool.weightedRefs ?? pool.refs;
362
+ if (strat === 'lowest-latency' || strat === 'least-loaded') {
363
+ list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
749
364
  }
750
- if (pool.perHour) {
751
- if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
752
- let win = pool.state.quotaWindows.get(candidate);
753
- if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
754
- if (win.count >= pool.perHour) {
755
- const until = win.start + 3600000;
756
- if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
757
- continue;
365
+ const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
366
+ for (let i = 0; i < list.length; i++) {
367
+ const index = (start + i) % list.length;
368
+ const candidate = list[index];
369
+ const until = pool.state.failedUntil.get(candidate);
370
+ if (until !== undefined && until > now) continue;
371
+ if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
372
+ const rpmLimit = pool.rpmLimit ?? 0;
373
+ if (rpmLimit > 0) {
374
+ if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
375
+ if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now)) {
376
+ const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, now);
377
+ if ((pool.state.failedUntil.get(candidate) ?? 0) < now + waitMs) {
378
+ pool.state.failedUntil.set(candidate, now + waitMs);
379
+ }
380
+ continue;
381
+ }
758
382
  }
759
- }
760
- // Advance pointer immediately so concurrent requests round-robin across distinct healthy keys
761
- pool.state.pointer = (index + 1) % list.length;
762
- let hit = await original(candidate);
763
- if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
764
- pool.state.lastUsed = candidate;
765
- if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
766
- pool.state.lastUsedAt.set(candidate, now);
767
- const store = dispatchStorage.getStore();
768
- if (store && store.pool === pool) store.pickedRef = candidate;
769
- if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
770
- pool.state.failedUntil.delete(candidate);
771
- if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
772
- if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
773
- if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
774
- pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
775
383
  if (pool.perHour) {
776
384
  if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
777
- let win2 = pool.state.quotaWindows.get(candidate);
778
- if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
779
- win2.count++;
780
- pool.state.quotaWindows.set(candidate, win2);
385
+ let win = pool.state.quotaWindows.get(candidate);
386
+ if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
387
+ if (win.count >= pool.perHour) {
388
+ const until = win.start + 3600000;
389
+ if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
390
+ continue;
391
+ }
781
392
  }
782
- return hit;
783
- }
784
- // fallback: env var (transient, not persisted)
785
- const envVal = envValue(candidate);
786
- if (envVal !== undefined) {
787
- pool.state.lastUsed = candidate;
788
- if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
789
- pool.state.lastUsedAt.set(candidate, now);
790
- const store = dispatchStorage.getStore();
791
- if (store && store.pool === pool) store.pickedRef = candidate;
792
- if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
793
- pool.state.failedUntil.delete(candidate);
794
- if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
795
- if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
796
- if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
797
- pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
798
- if (pool.perHour) {
799
- if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
800
- let win2 = pool.state.quotaWindows.get(candidate);
801
- if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
802
- win2.count++;
803
- pool.state.quotaWindows.set(candidate, win2);
393
+ pool.state.pointer = (index + 1) % list.length;
394
+ let hit = await original(candidate);
395
+ if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
396
+ pool.state.lastUsed = candidate;
397
+ if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
398
+ pool.state.lastUsedAt.set(candidate, now);
399
+ const store = dispatchStorage.getStore();
400
+ if (store && store.pool === pool) store.pickedRef = candidate;
401
+ if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
402
+ pool.state.failedUntil.delete(candidate);
403
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
404
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
405
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
406
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
407
+ if (pool.perHour) {
408
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
409
+ let win2 = pool.state.quotaWindows.get(candidate);
410
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
411
+ win2.count++;
412
+ pool.state.quotaWindows.set(candidate, win2);
413
+ }
414
+ return hit;
415
+ }
416
+ const envVal = envValue(candidate);
417
+ if (envVal !== undefined) {
418
+ pool.state.lastUsed = candidate;
419
+ if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
420
+ pool.state.lastUsedAt.set(candidate, now);
421
+ const store = dispatchStorage.getStore();
422
+ if (store && store.pool === pool) store.pickedRef = candidate;
423
+ if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
424
+ pool.state.failedUntil.delete(candidate);
425
+ if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
426
+ if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
427
+ if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
428
+ pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
429
+ if (pool.perHour) {
430
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
431
+ let win2 = pool.state.quotaWindows.get(candidate);
432
+ if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
433
+ win2.count++;
434
+ pool.state.quotaWindows.set(candidate, win2);
435
+ }
436
+ return { value: envVal, source: 'env' };
804
437
  }
805
- return { value: envVal, source: 'env' };
806
438
  }
807
- }
808
- return original(ref); // everything cooled/missing — surface the base value
439
+ return original(ref);
809
440
  };
810
441
  credentials.__dshKeyRotationPatched = true;
811
442
  return () => {
@@ -823,8 +454,6 @@ export function apply(ctx, config = {}) {
823
454
  reason: { kind: 'error', failure: Object.freeze({ code, message }) },
824
455
  });
825
456
 
826
- // Latency recording (#6): record successful llm/stream latency per ref.
827
- // ponytail: only the true success path (finish-chunk). Failures are not recorded.
828
457
  let _rotateStartMs = Date.now();
829
458
  function recordLatency(pool, reqStore) {
830
459
  try {
@@ -839,8 +468,7 @@ export function apply(ctx, config = {}) {
839
468
  } catch (_) { /* ponytail: never crash */ }
840
469
  }
841
470
 
842
- // rotate() factory (#253): dependencies injected for testability
843
- const rotate = createRotate({
471
+ const rotate = createRotate({
844
472
  ctx,
845
473
  dispatchStorage,
846
474
  buildRuntime,
@@ -856,14 +484,10 @@ export function apply(ctx, config = {}) {
856
484
  quotaStore,
857
485
  circuitBreaker: moduleBreaker,
858
486
  now: nowMono,
487
+ logger,
859
488
  });
860
489
 
861
- // Retry one request on the next pool key when the current key fails with a
862
- // switchable error before any content chunk. The provider never changes —
863
- // the resolve patch hands out the next key on each dispatch.
864
- // Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
865
490
  registerOpsRoutes(ctx, {
866
-
867
491
  buildRuntime,
868
492
  latencyHistogram,
869
493
  lastTestCache,
@@ -875,7 +499,6 @@ export function apply(ctx, config = {}) {
875
499
  quotaStore,
876
500
  });
877
501
 
878
- // One-click plugin updater (#307). GET status / POST install exact registry version.
879
502
  ctx.effect(() => {
880
503
  if (typeof ctx.webServer?.register !== 'function') return;
881
504
  registerPluginUpdater(ctx, {
@@ -887,27 +510,21 @@ export function apply(ctx, config = {}) {
887
510
 
888
511
  ctx.effect(() => ctx.on('llm/stream', (options, next) => {
889
512
  if (options[MARKER]) return next();
890
- if (rotationDisabled) return next(); // #199: disabled via webhook action
513
+ if (rotationDisabled) return next();
891
514
  const { providerToPool, modelPoolByProvider } = buildRuntime();
892
- // #195: exact model pool -> longest model-family prefix -> provider pool
893
515
  const pool = selectPool(modelPoolByProvider, providerToPool, options.provider, options.model);
894
516
  if (!pool) return next();
895
517
  if (buildRuntime()?.verboseLogging) {
896
- console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
518
+ logger.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
897
519
  }
898
520
  return rotate(options, pool);
899
521
  }), 'dsh-key-rotation: llm/stream');
900
522
 
901
- // Safety net for non-stream requests (agent/request-error waterfall).
902
- // llm/stream covers streaming calls; sync calls (embeddings, batch) go
903
- // through agent/request and surface errors here. If the error is
904
- // switchable, mark the key and ask the agent loop to retry.
905
523
  ctx.effect(() => ctx.on('agent/request-error', async (payload, next) => {
906
524
  const provider = payload?.provider ?? payload?.failure?.provider ?? '';
907
525
  if (!provider) return next();
908
526
  const { providerToPool, modelPoolByProvider, switchCodes } = buildRuntime();
909
527
  const model = payload?.model || payload?.failure?.model || '';
910
- // #195: same tier-aware selection as llm/stream
911
528
  const pool = selectPool(modelPoolByProvider, providerToPool, provider, model);
912
529
  if (!pool) return next();
913
530
  const code = String(payload?.failure?.code ?? payload?.code ?? '');
@@ -925,7 +542,7 @@ export function apply(ctx, config = {}) {
925
542
  pool.state.switches = (pool.state.switches ?? 0) + 1;
926
543
  pool.state.lastReason = code || 'UNKNOWN';
927
544
  pool.state.lastSwitchAt = Date.now();
928
- console.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
545
+ logger.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
929
546
  }
930
547
  return { kind: 'retry' };
931
548
  }), 'dsh-key-rotation: agent/request-error');
@@ -933,7 +550,7 @@ export function apply(ctx, config = {}) {
933
550
  ctx.inject(['settings'], (sctx) => {
934
551
  const settingsSvc = sctx.get('settings');
935
552
  if (!settingsSvc || typeof settingsSvc.register !== 'function') {
936
- console.warn('[dsh-key-rotation] settings service unavailable — config scope not registered');
553
+ logger.warn('[dsh-key-rotation] settings service unavailable — config scope not registered');
937
554
  return;
938
555
  }
939
556
  const scope = settingsSvc.register(NS, Config, { base: config });
@@ -943,33 +560,3 @@ export function apply(ctx, config = {}) {
943
560
  });
944
561
  });
945
562
  }
946
-
947
- // Notify on exhaustion: webhook notification.
948
- // Extracted at module scope for testability. No I/O outside the injected hooks.
949
- // ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
950
- export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
951
- if (!runtime || !pool) return;
952
- const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
953
- if (count <= 0) return;
954
- try {
955
- if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
956
- const token = runtime.webhookActionToken ?? '';
957
- const payload = {
958
- title: `Key pool exhausted: ${options.provider}`,
959
- text: `${count} exhaustion(s); keys: ${(pool.refs ?? []).join(', ')}`,
960
- provider: options.provider,
961
- exhaustionCount: count,
962
- at: pool.state.lastExhaustionAt,
963
- keys: pool.refs,
964
- actionToken: token || undefined,
965
- actions: token ? [
966
- { id: `reset-${options.provider}`, label: 'Reset cooldown' },
967
- { id: `pause-${options.provider}`, label: 'Pause 1h' },
968
- ] : undefined,
969
- };
970
- if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
971
- else hooks.webhookSender.send(runtime.notifyWebhook, payload);
972
- }
973
- } catch (_) { /* ponytail: never crash rotate() */ }
974
- }
975
-