@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/CHANGELOG.md +196 -0
- package/lib/budget-monitor.js +123 -0
- package/lib/client.js +22 -13
- package/lib/index.js +197 -559
- package/lib/lifecycle.js +122 -0
- package/lib/logger.js +13 -0
- package/lib/ops-status.js +1 -1
- package/lib/ops-webhook.js +3 -3
- package/lib/pool-builder.js +86 -0
- package/lib/rotate.js +10 -7
- 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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
let
|
|
92
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
510
|
-
let
|
|
214
|
+
const cfg = getConfig() ?? {};
|
|
215
|
+
let profilesSnapshot = null;
|
|
511
216
|
try {
|
|
512
|
-
|
|
217
|
+
profilesSnapshot = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
|
|
513
218
|
} catch {
|
|
514
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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'
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
752
|
-
if (!
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|