@goodandready/dsh-key-rotation 0.8.49 → 0.8.50
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 +9 -0
- package/lib/http-bridge.js +34 -25
- package/lib/index.js +24 -25
- package/lib/ops-status.js +11 -3
- package/lib/resolver.js +3 -3
- package/lib/rotate.js +1 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.50 - 2026-10-05
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Multi-Instance Isolation in Single Process (#446)**: Scoped all module-level state (`moduleBreaker`, `latencyHistogram`, `quotaStore`, `concurrencyTracker`, `webhookSender`, `alertDebouncer`, `moduleNotifyQueue`, and alert suppression maps `*NotifiedAt`) into `apply(ctx, config)` factory scope, eliminating shared state across profiles/instances.
|
|
7
|
+
- **Config Bridge GET Error Handling (#444)**: Wrapped `handleConfigBridge` GET branch in `try ... catch` to prevent unhandled promise rejections on catalog/settings provider read failures, safely returning HTTP 500 JSON with error code `settings-read-failed`.
|
|
8
|
+
- **Safe Settings Service Property Access (#447)**: Guarded `settings.writable` and `settings.documentPath` reads with defensive fallbacks and verified `typeof settings?.describe === function` in `descriptorOf`, `viewOf`, `writeSection`, and `handleConfigBridge`.
|
|
9
|
+
- **Clean Stream Consumer Finally Block (#445)**: Removed redundant empty `try { ... } finally { }` block in `lib/rotate.js`, keeping the outer resource cleanup block for concurrency permit and picked reference disposal.
|
|
10
|
+
- **Wired Diagnostics Pool Helpers (#440)**: Connected exported `providerOfBase` and `basePoolForBase` in `lib/resolver.js` (provider budget checks and exhaustion error formatting) and `lib/ops-status.js` (`/status` and `/health` routes, circuit breaker evaluation), and added `test/pool-index.test.js`.
|
|
11
|
+
|
|
3
12
|
## 0.8.49 - 2026-10-03
|
|
4
13
|
|
|
5
14
|
### Added
|
package/lib/http-bridge.js
CHANGED
|
@@ -47,16 +47,16 @@ export function readJson(request, maxBytes = 1024 * 1024) {
|
|
|
47
47
|
|
|
48
48
|
export function descriptorOf(ctx, ns) {
|
|
49
49
|
const settings = ctx.get('settings');
|
|
50
|
-
if (settings === void 0) return void 0;
|
|
51
|
-
return settings.describe({ redactSecrets: true })
|
|
50
|
+
if (settings === void 0 || typeof settings?.describe !== 'function') return void 0;
|
|
51
|
+
return settings.describe({ redactSecrets: true })?.find((candidate) => candidate.ns === ns);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function viewOf(descriptor, settings) {
|
|
55
55
|
return {
|
|
56
56
|
available: true,
|
|
57
|
-
writable: settings
|
|
58
|
-
hasDocument: settings
|
|
59
|
-
value: descriptor
|
|
57
|
+
writable: Boolean(settings?.writable),
|
|
58
|
+
hasDocument: Boolean(settings?.hasDocument),
|
|
59
|
+
value: descriptor?.value,
|
|
60
60
|
...descriptor.base === void 0 ? {} : { base: descriptor.base },
|
|
61
61
|
...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
|
|
62
62
|
revision: descriptor.revision,
|
|
@@ -90,7 +90,7 @@ export async function writeSection(ctx, ns, section, expectedRevision, res, rese
|
|
|
90
90
|
json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
json(res, 200, viewOf(descriptor, { writable: settings
|
|
93
|
+
json(res, 200, viewOf(descriptor, { writable: Boolean(settings?.writable), hasDocument: settings?.documentPath !== void 0 }));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
@@ -189,29 +189,38 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
189
189
|
}
|
|
190
190
|
const method = request.method ?? 'GET';
|
|
191
191
|
if (method === 'GET') {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
try {
|
|
193
|
+
const settings = ctx.get('settings');
|
|
194
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
195
|
+
const body = {
|
|
196
|
+
providers: providerCatalog(ctx, getCloneIds()),
|
|
197
|
+
};
|
|
198
|
+
if (descriptor === void 0) {
|
|
199
|
+
json(res, 200, {
|
|
200
|
+
...body,
|
|
201
|
+
available: false,
|
|
202
|
+
writable: Boolean(settings?.writable),
|
|
203
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
204
|
+
value: void 0,
|
|
205
|
+
revision: 0,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
198
209
|
json(res, 200, {
|
|
199
210
|
...body,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
...viewOf(descriptor, {
|
|
212
|
+
writable: Boolean(settings?.writable),
|
|
213
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
json(res, 500, {
|
|
218
|
+
error: {
|
|
219
|
+
code: 'settings-read-failed',
|
|
220
|
+
message: `dsh-key-rotation: failed to read settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
221
|
+
},
|
|
205
222
|
});
|
|
206
|
-
return;
|
|
207
223
|
}
|
|
208
|
-
json(res, 200, {
|
|
209
|
-
...body,
|
|
210
|
-
...viewOf(descriptor, {
|
|
211
|
-
writable: settings?.writable ?? false,
|
|
212
|
-
hasDocument: settings?.documentPath !== void 0,
|
|
213
|
-
}),
|
|
214
|
-
});
|
|
215
224
|
return;
|
|
216
225
|
}
|
|
217
226
|
if (method === 'PUT' || method === 'DELETE') {
|
package/lib/index.js
CHANGED
|
@@ -67,32 +67,9 @@ const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
|
67
67
|
const PIAI_NS = 'llm-pi-ai';
|
|
68
68
|
const MARKER = '__dshKeyRotation';
|
|
69
69
|
|
|
70
|
-
let rotationDisabled = false;
|
|
71
|
-
const expiryNotifiedAt = new Map();
|
|
72
|
-
const budgetNotifiedAt = new Map();
|
|
73
|
-
const lowHealthNotifiedAt = new Map();
|
|
74
|
-
const sloNotifiedAt = new Map();
|
|
75
|
-
|
|
76
70
|
const dispatchStorage = new AsyncLocalStorage();
|
|
77
|
-
const latencyHistogram = new LatencyHistogram();
|
|
78
|
-
const quotaStore = new QuotaStore();
|
|
79
|
-
const concurrencyTracker = new ConcurrencyTracker();
|
|
80
|
-
|
|
81
|
-
let moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
|
|
82
|
-
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
83
|
-
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
84
|
-
let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
85
|
-
|
|
86
|
-
let getConfig = () => null;
|
|
87
|
-
function verboseLoggingOn() {
|
|
88
|
-
try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
71
|
let getRuntime = () => null;
|
|
92
72
|
|
|
93
|
-
const sandboxService = createSandboxService({ getRuntime: () => getRuntime() });
|
|
94
|
-
const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
95
|
-
|
|
96
73
|
const DEFAULT_PROVIDERS = [];
|
|
97
74
|
|
|
98
75
|
const SettingsSchema = Schema.object({
|
|
@@ -189,7 +166,29 @@ function registerConfigBridge(ctx, getCloneIds) {
|
|
|
189
166
|
export function apply(ctx, config = {}) {
|
|
190
167
|
const logger = getLogger(ctx);
|
|
191
168
|
const readEntryConfig = createConfigReader(config, ctx);
|
|
192
|
-
getConfig = readEntryConfig;
|
|
169
|
+
let getConfig = readEntryConfig;
|
|
170
|
+
const verboseLoggingOn = () => {
|
|
171
|
+
try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let rotationDisabled = false;
|
|
175
|
+
const expiryNotifiedAt = new Map();
|
|
176
|
+
const budgetNotifiedAt = new Map();
|
|
177
|
+
const lowHealthNotifiedAt = new Map();
|
|
178
|
+
const sloNotifiedAt = new Map();
|
|
179
|
+
|
|
180
|
+
const latencyHistogram = new LatencyHistogram();
|
|
181
|
+
const quotaStore = new QuotaStore();
|
|
182
|
+
const concurrencyTracker = new ConcurrencyTracker();
|
|
183
|
+
|
|
184
|
+
const moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
|
|
185
|
+
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
186
|
+
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
187
|
+
const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
188
|
+
|
|
189
|
+
const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
|
|
190
|
+
const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
191
|
+
|
|
193
192
|
const readProfiles = createProviderProfilesReader(ctx, PIAI_NS);
|
|
194
193
|
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
195
194
|
ensureSandboxRunner(ctx);
|
|
@@ -340,7 +339,7 @@ export function apply(ctx, config = {}) {
|
|
|
340
339
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
341
340
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
342
341
|
index, pools,
|
|
343
|
-
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue,
|
|
342
|
+
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram,
|
|
344
343
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
345
344
|
cloneIds, expectedClones,
|
|
346
345
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
package/lib/ops-status.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
|
|
2
|
+
import { providerOfBase, basePoolForBase } from './pool-index.js';
|
|
2
3
|
import { decryptSecret } from './crypto-storage.js';
|
|
3
4
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
4
5
|
import { json } from './http-bridge.js';
|
|
@@ -119,8 +120,11 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
119
120
|
modelQuota,
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
const basePool = pool.basePool ?? (runtime.index ? basePoolForBase(runtime.index, pool.base) : null);
|
|
122
124
|
providers.push({
|
|
123
125
|
provider: pool.base,
|
|
126
|
+
providerName: pool.provider ?? providerOfBase(pool.base),
|
|
127
|
+
basePool: basePool?.base ?? null,
|
|
124
128
|
model: pool.model ?? null,
|
|
125
129
|
quotasConfigured: hasModelQuotaConfig(pool),
|
|
126
130
|
keys,
|
|
@@ -130,8 +134,9 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
130
134
|
const br = circuitBreaker ?? runtime.breaker;
|
|
131
135
|
if (!br) return null;
|
|
132
136
|
let st = br.state(pool.base);
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const provKey = pool.provider ?? providerOfBase(pool.base);
|
|
138
|
+
if (st === 'closed' && provKey && provKey !== pool.base) {
|
|
139
|
+
const baseSt = br.state(provKey);
|
|
135
140
|
if (baseSt !== 'closed') st = baseSt;
|
|
136
141
|
}
|
|
137
142
|
return { state: st, threshold: br.threshold, openMs: br.openMs };
|
|
@@ -212,12 +217,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
212
217
|
const total = pool.refs.length;
|
|
213
218
|
const exhausted = healthy === 0 && total > 0;
|
|
214
219
|
if (exhausted) exhaustedAny = true;
|
|
220
|
+
const basePoolH = pool.basePool ?? (runtimeH.index ? basePoolForBase(runtimeH.index, pool.base) : null);
|
|
215
221
|
pools[pool.base] = {
|
|
216
222
|
healthy,
|
|
217
223
|
total,
|
|
218
224
|
exhausted,
|
|
219
225
|
healthScore: computeHealthScore(pool.state),
|
|
220
226
|
model: pool.model ?? null,
|
|
227
|
+
providerName: pool.provider ?? providerOfBase(pool.base),
|
|
228
|
+
basePool: basePoolH?.base ?? null,
|
|
221
229
|
modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
|
|
222
230
|
};
|
|
223
231
|
}
|
|
@@ -225,7 +233,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
225
233
|
const brH = circuitBreaker ?? runtimeH.breaker;
|
|
226
234
|
if (brH) {
|
|
227
235
|
for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
|
|
228
|
-
const provKey = pool.provider
|
|
236
|
+
const provKey = pool.provider ?? providerOfBase(pool.base);
|
|
229
237
|
if (brH.state(pool.base) === 'open' || brH.state(provKey) === 'open') {
|
|
230
238
|
circuitOpenAny = true;
|
|
231
239
|
break;
|
package/lib/resolver.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
|
|
12
12
|
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
13
13
|
import { decryptSecret } from './crypto-storage.js';
|
|
14
|
-
import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
|
|
14
|
+
import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
|
|
15
15
|
import {
|
|
16
16
|
isModelQuotaAvailable,
|
|
17
17
|
hasModelQuotaConfig,
|
|
@@ -101,7 +101,7 @@ export function createResolver({
|
|
|
101
101
|
const at = now();
|
|
102
102
|
|
|
103
103
|
// Provider monetary budget refusal (#422):
|
|
104
|
-
const providerName = pool.provider ?? pool.base
|
|
104
|
+
const providerName = pool.provider ?? providerOfBase(pool.base);
|
|
105
105
|
const budget = runtime.providerBudgets?.get?.(providerName);
|
|
106
106
|
if (budget && budget.pauseOnBudget) {
|
|
107
107
|
const { daily, weekly } = getProviderCost(providerName, runtime, at);
|
|
@@ -221,7 +221,7 @@ export function createResolver({
|
|
|
221
221
|
// local refusal: it is not penalised and not classified as an upstream failure.
|
|
222
222
|
if (quotaManaged && !anyModelQuotaAvailable(pool, at)) {
|
|
223
223
|
throw Object.assign(
|
|
224
|
-
new Error(formatModelQuotaExhaustion(pool.provider ?? pool.base, pool)),
|
|
224
|
+
new Error(formatModelQuotaExhaustion(pool.provider ?? providerOfBase(pool.base), pool)),
|
|
225
225
|
{ code: LOCAL_QUOTA_CODE, localQuota: true },
|
|
226
226
|
);
|
|
227
227
|
}
|
package/lib/rotate.js
CHANGED
|
@@ -287,8 +287,7 @@ export function createRotate(deps) {
|
|
|
287
287
|
|
|
288
288
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
289
289
|
try {
|
|
290
|
-
|
|
291
|
-
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
290
|
+
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
292
291
|
if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
|
|
293
292
|
const fallbackRef = reqStore.pickedRef ?? _pickedRef;
|
|
294
293
|
if (fallbackRef) {
|
|
@@ -464,8 +463,6 @@ export function createRotate(deps) {
|
|
|
464
463
|
activePermit?.success();
|
|
465
464
|
}
|
|
466
465
|
return; // clean end — served
|
|
467
|
-
} finally {
|
|
468
|
-
}
|
|
469
466
|
} finally {
|
|
470
467
|
if (reqStore.concurrencyRef) {
|
|
471
468
|
concurrencyTracker?.release(reqStore.concurrencyRef);
|
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.50",
|
|
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": [
|