@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/CHANGELOG.md +196 -0
- package/lib/budget-monitor.js +123 -0
- package/lib/client.js +22 -13
- package/lib/index.js +199 -612
- package/lib/lifecycle.js +122 -0
- package/lib/logger.js +13 -0
- package/lib/notify-events.js +64 -0
- package/lib/ops-keys.js +189 -0
- package/lib/ops-paths.js +11 -0
- package/lib/ops-status.js +202 -0
- package/lib/ops-telemetry.js +113 -0
- package/lib/ops-test.js +106 -0
- package/lib/ops-webhook.js +115 -0
- package/lib/pool-builder.js +86 -0
- package/lib/rotate.js +10 -7
- package/lib/routes-ops.js +12 -631
- package/lib/sandbox-service.js +55 -0
- package/package.json +4 -1
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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 {
|
|
54
|
-
import
|
|
55
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
536
|
-
let
|
|
214
|
+
const cfg = getConfig() ?? {};
|
|
215
|
+
let profilesSnapshot = null;
|
|
537
216
|
try {
|
|
538
|
-
|
|
217
|
+
profilesSnapshot = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
|
|
539
218
|
} catch {
|
|
540
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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'
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
778
|
-
if (!
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|