@goodandready/dsh-key-rotation 0.8.57 → 0.8.59
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 +25 -0
- package/lib/config-compat.js +7 -19
- package/lib/index.js +8 -16
- package/lib/lifecycle.js +12 -1
- package/lib/ops-status.js +3 -0
- package/lib/rotate.js +21 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.59 - 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Refactored & Cleaned
|
|
6
|
+
- **Legacy Settings Registration Removed (#482)**:
|
|
7
|
+
- Deleted dead legacy `settings.register` branch in `lib/index.js`, fixing static configuration liveness to use standard `createConfigReader`.
|
|
8
|
+
- **Dead Subscriptions & Settings Fallbacks Removed (#482)**:
|
|
9
|
+
- Removed dead `ctx.on('config')` subscription in `lib/config-compat.js`.
|
|
10
|
+
- Removed unreachable legacy `settings.get` fallback in `createProviderProfilesReader` in favor of standard `settings.describe`.
|
|
11
|
+
- **Test Harness Synchronization (#482)**:
|
|
12
|
+
- Updated test fixtures for modern descriptor caching and multi-listener lifecycle events.
|
|
13
|
+
|
|
14
|
+
## 0.8.58 - 2026-10-13
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **Early-Warning Notification Dispatch (#477)**:
|
|
18
|
+
- Connected `notifyEarlyWarning` during key penalization in `lib/rotate.js` when healthy pool capacity drops to or below `earlyWarningKeyThreshold`.
|
|
19
|
+
- Added export `notifyEarlyWarning` in `lib/index.js` and wired dependency through `createRotate`.
|
|
20
|
+
- **Proactive Health Probing Logging (#478)**:
|
|
21
|
+
- Replaced empty catch block in `lib/lifecycle.js` `setupProactiveHealthProbeEffect` with structured debug logging (`logger?.debug`), eliminating silent swallowed exceptions.
|
|
22
|
+
- **Key Drain Lifecycle Teardown (#479)**:
|
|
23
|
+
- Registered `setupKeyDrainEffect` in `lib/lifecycle.js` to ensure module-level `drainingKeys` map is wiped cleanly on plugin teardown/dispose via `clearAllDraining()`.
|
|
24
|
+
- **Quota Reset Window Schema Validation (#480)**:
|
|
25
|
+
- Connected `QUOTA_WINDOW_TYPES` into `SettingsSchema` via `Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc')`.
|
|
26
|
+
- Connected `isBlockedUntilReset` in `lib/ops-status.js` to accurately expose calendar-based reset lock status.
|
|
27
|
+
|
|
3
28
|
## 0.8.57 - 2026-10-12
|
|
4
29
|
|
|
5
30
|
### Added
|
package/lib/config-compat.js
CHANGED
|
@@ -10,11 +10,9 @@ export function createConfigReader(config, ctx) {
|
|
|
10
10
|
if (typeof ctx.effect === 'function') {
|
|
11
11
|
ctx.effect(() => ctx.on('settings/document-updated', markDirty));
|
|
12
12
|
ctx.effect(() => ctx.on('loader/volatile-update', markDirty));
|
|
13
|
-
ctx.effect(() => ctx.on('config', markDirty));
|
|
14
13
|
} else {
|
|
15
14
|
ctx.on('settings/document-updated', markDirty);
|
|
16
15
|
ctx.on('loader/volatile-update', markDirty);
|
|
17
|
-
ctx.on('config', markDirty);
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
18
|
|
|
@@ -68,23 +66,13 @@ export function createProviderProfilesReader(ctx, namespace) {
|
|
|
68
66
|
return held;
|
|
69
67
|
}
|
|
70
68
|
const settings = ctx?.get?.('settings');
|
|
71
|
-
if (!settings) return null;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
dirty = false;
|
|
79
|
-
}
|
|
80
|
-
} else {
|
|
81
|
-
try {
|
|
82
|
-
held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
|
|
83
|
-
dirty = false;
|
|
84
|
-
} catch (_) {
|
|
85
|
-
held = null;
|
|
86
|
-
dirty = false;
|
|
87
|
-
}
|
|
69
|
+
if (!settings || typeof settings.describe !== 'function') return null;
|
|
70
|
+
try {
|
|
71
|
+
held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
|
|
72
|
+
dirty = false;
|
|
73
|
+
} catch (_) {
|
|
74
|
+
held = null;
|
|
75
|
+
dirty = false;
|
|
88
76
|
}
|
|
89
77
|
return held;
|
|
90
78
|
};
|
package/lib/index.js
CHANGED
|
@@ -31,7 +31,8 @@ import { NotifyQueue } from './notify-queue.js';
|
|
|
31
31
|
import { classifyFailure } from './error-taxonomy.js';
|
|
32
32
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
33
33
|
import { registerPluginUpdater } from './plugin-updater.js';
|
|
34
|
-
import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
34
|
+
import { notifySwitch, notifyExhaustion, notifyEarlyWarning, pushEvent } from './notify-events.js';
|
|
35
|
+
import { QUOTA_WINDOW_TYPES } from './quota-window.js';
|
|
35
36
|
import { getLogger } from './logger.js';
|
|
36
37
|
import { createSandboxService } from './sandbox-service.js';
|
|
37
38
|
import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
|
|
@@ -44,12 +45,12 @@ import {
|
|
|
44
45
|
formatModelQuotaExhaustion,
|
|
45
46
|
} from './model-quota.js';
|
|
46
47
|
import { LOCAL_QUOTA_CODE } from './rotate.js';
|
|
47
|
-
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect } from './lifecycle.js';
|
|
48
|
+
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect, setupKeyDrainEffect } from './lifecycle.js';
|
|
48
49
|
|
|
49
50
|
export const name = '@goodandready/dsh-key-rotation';
|
|
50
51
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
51
52
|
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
52
|
-
export { notifySwitch, notifyExhaustion };
|
|
53
|
+
export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
53
54
|
|
|
54
55
|
const NS = 'dsh-key-rotation';
|
|
55
56
|
|
|
@@ -88,7 +89,7 @@ const SettingsSchema = Schema.object({
|
|
|
88
89
|
model: Schema.string(),
|
|
89
90
|
})).default([]),
|
|
90
91
|
quotaResetWindow: Schema.object({
|
|
91
|
-
type: Schema.
|
|
92
|
+
type: Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc'),
|
|
92
93
|
hour: Schema.number().default(0),
|
|
93
94
|
}),
|
|
94
95
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
@@ -177,7 +178,7 @@ function registerConfigBridge(ctx, getCloneIds) {
|
|
|
177
178
|
export function apply(ctx, config = {}) {
|
|
178
179
|
const logger = getLogger(ctx);
|
|
179
180
|
const readEntryConfig = createConfigReader(config, ctx);
|
|
180
|
-
|
|
181
|
+
const getConfig = readEntryConfig;
|
|
181
182
|
const verboseLoggingOn = () => {
|
|
182
183
|
try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
|
|
183
184
|
};
|
|
@@ -206,6 +207,7 @@ export function apply(ctx, config = {}) {
|
|
|
206
207
|
|
|
207
208
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
208
209
|
setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
210
|
+
setupKeyDrainEffect(ctx);
|
|
209
211
|
|
|
210
212
|
const poolState = new Map();
|
|
211
213
|
const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
|
|
@@ -421,6 +423,7 @@ export function apply(ctx, config = {}) {
|
|
|
421
423
|
pushEvent,
|
|
422
424
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
423
425
|
notifyExhaustion: (runtime, pool, options) => notifyExhaustion(runtime, pool, options, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
426
|
+
notifyEarlyWarning: (runtime, pool, info) => notifyEarlyWarning(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
424
427
|
recordLatency,
|
|
425
428
|
latencyHistogram,
|
|
426
429
|
concurrencyTracker,
|
|
@@ -498,15 +501,4 @@ export function apply(ctx, config = {}) {
|
|
|
498
501
|
return next();
|
|
499
502
|
}), 'dsh-key-rotation: agent/request-error');
|
|
500
503
|
|
|
501
|
-
ctx.inject(['settings'], (sctx) => {
|
|
502
|
-
const settingsSvc = sctx.get('settings');
|
|
503
|
-
// Modern SettingsForms already owns this Loader entry's volatile Config.
|
|
504
|
-
// Legacy SettingsProvider instead needs a plain namespace registration.
|
|
505
|
-
if (typeof settingsSvc?.register !== 'function') return;
|
|
506
|
-
const scope = settingsSvc.register(NS, SettingsSchema, { base: readEntryConfig() });
|
|
507
|
-
getConfig = () => scope.get() ?? readEntryConfig();
|
|
508
|
-
if (typeof sctx?.effect === 'function') {
|
|
509
|
-
sctx.effect(() => () => { getConfig = readEntryConfig; });
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
504
|
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -4,6 +4,7 @@ import { decryptSecret } from './crypto-storage.js';
|
|
|
4
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { envValue } from './pool.js';
|
|
7
|
+
import { clearAllDraining } from './key-drain.js';
|
|
7
8
|
|
|
8
9
|
export function setupIdleHealEffect(ctx, getConfig, buildRuntime, timers = globalThis) {
|
|
9
10
|
return ctx.effect(() => {
|
|
@@ -209,7 +210,9 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
209
210
|
}
|
|
210
211
|
logger?.warn?.(`[dsh-key-rotation] proactive-probe: restored key ${ref} in pool ${pool.base}`);
|
|
211
212
|
}
|
|
212
|
-
} catch (
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logger?.debug?.(`[dsh-key-rotation] proactive-probe failed for ${ref}: ${err?.message || err}`);
|
|
215
|
+
}
|
|
213
216
|
}
|
|
214
217
|
}
|
|
215
218
|
} catch (e) {
|
|
@@ -244,3 +247,11 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
244
247
|
};
|
|
245
248
|
}, 'dsh-key-rotation: proactive health probing');
|
|
246
249
|
}
|
|
250
|
+
|
|
251
|
+
export function setupKeyDrainEffect(ctx) {
|
|
252
|
+
return ctx.effect(() => {
|
|
253
|
+
return () => {
|
|
254
|
+
clearAllDraining();
|
|
255
|
+
};
|
|
256
|
+
}, 'dsh-key-rotation: clear draining keys on teardown');
|
|
257
|
+
}
|
package/lib/ops-status.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
isLoopbackAddress,
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { bucketInfo, tpmInfo } from './bucket.js';
|
|
18
|
+
import { isBlockedUntilReset } from './quota-window.js';
|
|
18
19
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
19
20
|
import {
|
|
20
21
|
STATUS_PATH,
|
|
@@ -92,6 +93,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
92
93
|
/* describe is optional — the card falls back to editable */
|
|
93
94
|
}
|
|
94
95
|
const until = pool.state.failedUntil?.get(ref);
|
|
96
|
+
const quotaWin = pool.state.quotaWindows?.get(ref);
|
|
95
97
|
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
96
98
|
const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
|
|
97
99
|
keys.push({
|
|
@@ -102,6 +104,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
102
104
|
writable,
|
|
103
105
|
active: pool.state.lastUsed === ref,
|
|
104
106
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
107
|
+
blockedUntilReset: isBlockedUntilReset(until, quotaWin, now),
|
|
105
108
|
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
106
109
|
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
107
110
|
// TPM capacity snapshot (null when tpmLimit is off)
|
package/lib/rotate.js
CHANGED
|
@@ -19,6 +19,7 @@ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
|
19
19
|
import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
|
|
20
20
|
import { createSelectionTrace } from './selection-trace.js';
|
|
21
21
|
import { isKeyDraining } from './key-drain.js';
|
|
22
|
+
import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
|
|
22
23
|
import { nextQuotaReset, poolResetAt } from './quota-window.js';
|
|
23
24
|
import { tpmRecord } from './bucket.js';
|
|
24
25
|
import {
|
|
@@ -64,6 +65,7 @@ export function createRotate(deps) {
|
|
|
64
65
|
pushEvent,
|
|
65
66
|
notifySwitch,
|
|
66
67
|
notifyExhaustion,
|
|
68
|
+
notifyEarlyWarning,
|
|
67
69
|
recordLatency,
|
|
68
70
|
latencyHistogram,
|
|
69
71
|
concurrencyTracker,
|
|
@@ -77,6 +79,7 @@ export function createRotate(deps) {
|
|
|
77
79
|
now = nowMono,
|
|
78
80
|
} = deps;
|
|
79
81
|
|
|
82
|
+
const effectiveNotifyEarlyWarning = notifyEarlyWarning ?? defaultNotifyEarlyWarning;
|
|
80
83
|
const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
|
|
81
84
|
|
|
82
85
|
function rotate(options, pool) {
|
|
@@ -204,6 +207,24 @@ export function createRotate(deps) {
|
|
|
204
207
|
if (typeof schedulePersist === 'function') {
|
|
205
208
|
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
206
209
|
}
|
|
210
|
+
if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
|
|
211
|
+
const total = pool.refs?.length || 1;
|
|
212
|
+
let available = 0;
|
|
213
|
+
const at = now();
|
|
214
|
+
for (const r of (pool.refs || [])) {
|
|
215
|
+
if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
|
|
216
|
+
available++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
|
|
220
|
+
if (total > 0 && (available / total) <= threshold) {
|
|
221
|
+
effectiveNotifyEarlyWarning(runtime0, pool, {
|
|
222
|
+
provider: options.provider,
|
|
223
|
+
availableCount: available,
|
|
224
|
+
totalCount: total,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
207
228
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
208
229
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
209
230
|
const _cStr = String(errCode ?? '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.59",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|