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