@goodandready/dsh-key-rotation 0.8.66 → 0.8.68

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.
@@ -0,0 +1,162 @@
1
+ // lib/ops-analytics.js — 24h error breakdown, load distribution, and health index analytics (#505).
2
+ // Pure helpers with zero dependencies.
3
+
4
+ const DAY_MS = 86400000;
5
+ const MAX_RING_BUFFER = 200;
6
+
7
+ /**
8
+ * Classifies an error code or message into canonical categories:
9
+ * 'rateLimit' | 'server' | 'timeout' | 'quota' | 'auth' | 'other'
10
+ */
11
+ export function classifyErrorCategory(code, message = '') {
12
+ const c = String(code ?? '').toUpperCase();
13
+ const m = String(message ?? '').toLowerCase();
14
+
15
+ if (c === '429' || c.includes('RATE_LIMIT') || m.includes('rate limit') || m.includes('too many requests')) {
16
+ return 'rateLimit';
17
+ }
18
+ if (/^5\d\d$/.test(c) || c === 'SERVER' || c === 'BAD_GATEWAY' || c === 'SERVICE_UNAVAILABLE' || m.includes('internal server error')) {
19
+ return 'server';
20
+ }
21
+ if (c.includes('TIMEOUT') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out')) {
22
+ return 'timeout';
23
+ }
24
+ if (c === 'QUOTA' || c === '402' || c.includes('QUOTA') || m.includes('quota') || m.includes('credit') || m.includes('insufficient_quota')) {
25
+ return 'quota';
26
+ }
27
+ if (c === 'AUTH' || c === '401' || c === '403' || m.includes('unauthorized') || m.includes('invalid_api_key') || m.includes('forbidden')) {
28
+ return 'auth';
29
+ }
30
+ return 'other';
31
+ }
32
+
33
+ function initPoolAnalytics(pool) {
34
+ if (!pool.state.analytics24h) {
35
+ pool.state.analytics24h = {
36
+ requests: [], // Ring buffer of { at, ref, success, attempts, failovers, cascaded }
37
+ errorEvents: [], // Ring buffer of { at, category, code, ref }
38
+ };
39
+ }
40
+ return pool.state.analytics24h;
41
+ }
42
+
43
+ /**
44
+ * Records a failover error encountered during key rotation.
45
+ */
46
+ export function recordFailureCategory(pool, code, message = '', ref = '', now = Date.now()) {
47
+ if (!pool?.state) return;
48
+ const a = initPoolAnalytics(pool);
49
+ const category = classifyErrorCategory(code, message);
50
+ a.errorEvents.push({
51
+ at: now,
52
+ category,
53
+ code: String(code ?? 'UNKNOWN'),
54
+ ref: ref || pool.state.lastUsed || '',
55
+ });
56
+ if (a.errorEvents.length > MAX_RING_BUFFER) {
57
+ a.errorEvents.shift();
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Records a completed request outcome in the pool's 24h analytics buffer.
63
+ */
64
+ export function recordRequestOutcome(pool, outcome, now = Date.now()) {
65
+ if (!pool?.state) return;
66
+ const a = initPoolAnalytics(pool);
67
+
68
+ const entry = {
69
+ at: now,
70
+ ref: outcome.ref || pool.state.lastUsed || '',
71
+ success: outcome.success !== false,
72
+ attempts: Number(outcome.attempts) || 1,
73
+ failovers: Number(outcome.failovers) || 0,
74
+ cascaded: Boolean(outcome.cascaded),
75
+ };
76
+
77
+ a.requests.push(entry);
78
+ if (a.requests.length > MAX_RING_BUFFER) {
79
+ a.requests.shift();
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Computes 24h analytics snapshot for the pool.
85
+ */
86
+ export function compute24hAnalytics(pool, now = Date.now()) {
87
+ const cutoff = now - DAY_MS;
88
+ const a = pool?.state?.analytics24h;
89
+ const requests = a?.requests?.filter(r => r.at >= cutoff) ?? [];
90
+ const errorEvents = a?.errorEvents?.filter(e => e.at >= cutoff) ?? [];
91
+
92
+ const totalRequests = requests.length;
93
+ let firstAttemptSuccesses = 0;
94
+ let cascades = 0;
95
+ const keyCounts = new Map();
96
+ const errors = {
97
+ rateLimit: 0,
98
+ server: 0,
99
+ timeout: 0,
100
+ quota: 0,
101
+ auth: 0,
102
+ other: 0,
103
+ };
104
+
105
+ for (const r of requests) {
106
+ if (r.ref) {
107
+ keyCounts.set(r.ref, (keyCounts.get(r.ref) ?? 0) + 1);
108
+ }
109
+ if (r.success && r.attempts <= 1 && r.failovers === 0) {
110
+ firstAttemptSuccesses++;
111
+ }
112
+ if (r.cascaded) {
113
+ cascades++;
114
+ }
115
+ }
116
+
117
+ for (const ev of errorEvents) {
118
+ if (ev.category && errors[ev.category] != null) {
119
+ errors[ev.category]++;
120
+ }
121
+ }
122
+
123
+ // Calculate load distribution across pool refs
124
+ const refs = pool?.refs ?? [...keyCounts.keys()];
125
+ const loadDistribution = refs.map(ref => {
126
+ const count = keyCounts.get(ref) ?? 0;
127
+ const pct = totalRequests > 0 ? Math.round((count / totalRequests) * 100) : 0;
128
+ return { ref, count, pct };
129
+ });
130
+
131
+ const totalErrors = Object.values(errors).reduce((sum, n) => sum + n, 0);
132
+
133
+ // Compute average cooldown recovery time from pool.state.events
134
+ let cooldownTotalMs = 0;
135
+ let cooldownCount = 0;
136
+ const events = pool?.state?.events ?? [];
137
+ for (const ev of events) {
138
+ if (ev.at >= cutoff && ev.type === 'fail' && Number.isFinite(ev.cooldownMs) && ev.cooldownMs > 0) {
139
+ cooldownTotalMs += ev.cooldownMs;
140
+ cooldownCount++;
141
+ }
142
+ }
143
+ const avgCooldownRecoveryMs = cooldownCount > 0 ? Math.round(cooldownTotalMs / cooldownCount) : 0;
144
+
145
+ // Health index: % of requests handled without retries or failover
146
+ const healthIndexPct = totalRequests > 0
147
+ ? Math.round((firstAttemptSuccesses / totalRequests) * 100)
148
+ : 100;
149
+
150
+ return {
151
+ totalRequests,
152
+ firstAttemptSuccesses,
153
+ healthIndexPct,
154
+ loadDistribution,
155
+ errorBreakdown: {
156
+ ...errors,
157
+ total: totalErrors,
158
+ },
159
+ cascades: Math.max(cascades, pool?.state?.cascadeCount ?? 0),
160
+ avgCooldownRecoveryMs,
161
+ };
162
+ }
@@ -0,0 +1,325 @@
1
+ // lib/ops-config-io.js — full configuration export and import (#504).
2
+ import crypto from 'node:crypto';
3
+ import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
4
+ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
5
+ import { keyTail } from './pool.js';
6
+ import { plainConfig } from './index.js';
7
+
8
+ export function encryptConfig(plainText, passphrase) {
9
+ const salt = crypto.randomBytes(16);
10
+ const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
11
+ const iv = crypto.randomBytes(12);
12
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
13
+ const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
14
+ const tag = cipher.getAuthTag();
15
+ return {
16
+ encrypted: true,
17
+ algorithm: 'aes-256-gcm',
18
+ kdf: 'pbkdf2',
19
+ iterations: 100000,
20
+ salt: salt.toString('hex'),
21
+ iv: iv.toString('hex'),
22
+ tag: tag.toString('hex'),
23
+ ciphertext: encrypted.toString('hex'),
24
+ };
25
+ }
26
+
27
+ export function decryptConfig(payload, passphrase) {
28
+ if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
29
+ throw new Error('Invalid encrypted config payload structure');
30
+ }
31
+ const salt = Buffer.from(payload.salt, 'hex');
32
+ const iv = Buffer.from(payload.iv, 'hex');
33
+ const tag = Buffer.from(payload.tag, 'hex');
34
+ const ciphertext = Buffer.from(payload.ciphertext, 'hex');
35
+ const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
36
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
37
+ decipher.setAuthTag(tag);
38
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
39
+ return decrypted.toString('utf8');
40
+ }
41
+
42
+ export function maskConfig(config) {
43
+ if (!config || typeof config !== 'object') return config;
44
+ const clone = JSON.parse(JSON.stringify(config));
45
+ if (Array.isArray(clone.providers)) {
46
+ for (const prov of clone.providers) {
47
+ if (Array.isArray(prov.keys)) {
48
+ prov.keys = prov.keys.map((k) => {
49
+ if (typeof k === 'string') return k.length > 8 ? `sk-...${k.slice(-4)}` : '***';
50
+ if (k && typeof k === 'object') {
51
+ return {
52
+ ...k,
53
+ value: typeof k.value === 'string' ? (k.value.length > 8 ? `sk-...${k.value.slice(-4)}` : '***') : '***',
54
+ };
55
+ }
56
+ return '***';
57
+ });
58
+ }
59
+ }
60
+ }
61
+ if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
62
+ try {
63
+ const u = new URL(clone.webhookUrl);
64
+ const parts = u.pathname.split('/');
65
+ if (parts.length > 2) {
66
+ parts[parts.length - 1] = '***';
67
+ u.pathname = parts.join('/');
68
+ }
69
+ clone.webhookUrl = u.toString();
70
+ } catch (_) {
71
+ clone.webhookUrl = '***';
72
+ }
73
+ }
74
+ if (clone.webhookActionToken) clone.webhookActionToken = '***';
75
+ return clone;
76
+ }
77
+
78
+ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
79
+ const currentProviders = Array.isArray(currentConfig?.providers) ? currentConfig.providers : [];
80
+ const incomingProviders = Array.isArray(incomingConfig?.providers) ? incomingConfig.providers : [];
81
+
82
+ const currentMap = new Map();
83
+ for (const p of currentProviders) {
84
+ const name = p.provider || p.name || p.id;
85
+ if (name) currentMap.set(name, p);
86
+ }
87
+
88
+ const createdProviders = [];
89
+ const updatedProviders = [];
90
+ let keysAdded = 0;
91
+ let keysUpdated = 0;
92
+
93
+ for (const p of incomingProviders) {
94
+ const name = p.provider || p.name || p.id;
95
+ if (!name) continue;
96
+ const inKeys = Array.isArray(p.keys) ? p.keys : [];
97
+ if (!currentMap.has(name)) {
98
+ createdProviders.push(name);
99
+ keysAdded += inKeys.length;
100
+ } else {
101
+ updatedProviders.push(name);
102
+ const curP = currentMap.get(name);
103
+ const curKeys = new Set(Array.isArray(curP.keys) ? curP.keys.map((k) => (typeof k === 'string' ? k : k?.value)) : []);
104
+ for (const k of inKeys) {
105
+ const val = typeof k === 'string' ? k : k?.value;
106
+ if (!curKeys.has(val)) keysAdded++;
107
+ else keysUpdated++;
108
+ }
109
+ }
110
+ }
111
+
112
+ const removedProviders = [];
113
+ if (mode === 'replace') {
114
+ const incomingNames = new Set(incomingProviders.map((p) => p.provider || p.name || p.id).filter(Boolean));
115
+ for (const [name] of currentMap.entries()) {
116
+ if (!incomingNames.has(name)) removedProviders.push(name);
117
+ }
118
+ }
119
+
120
+ const totalKeys = incomingProviders.reduce((acc, p) => acc + (Array.isArray(p.keys) ? p.keys.length : 0), 0);
121
+
122
+ return {
123
+ mode,
124
+ createdProviders,
125
+ updatedProviders,
126
+ removedProviders,
127
+ keysAdded,
128
+ keysUpdated,
129
+ totalKeys,
130
+ };
131
+ }
132
+
133
+ export function registerConfigIoRoutes(ctx, deps) {
134
+ // ── GET /dsh-key-rotation/export ──
135
+ ctx.effect(() => ctx.webServer.register({
136
+ kind: 'exact',
137
+ path: CONFIG_EXPORT_PATH,
138
+ handler: async (req, res) => {
139
+ if (req.method !== 'GET') {
140
+ json(res, 405, { error: { code: 'method', message: 'GET only' } });
141
+ return;
142
+ }
143
+ if (!isTrustedBridgeRequest(req)) {
144
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: export is local-only' } });
145
+ return;
146
+ }
147
+
148
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
149
+ const maskedParam = urlObj.searchParams.get('masked');
150
+ const masked = maskedParam === null ? true : maskedParam !== 'false';
151
+ const passphrase = urlObj.searchParams.get('passphrase') || null;
152
+
153
+ const settings = ctx.get('settings');
154
+ let currentConfig = {};
155
+ try {
156
+ if (settings && typeof settings.get === 'function') {
157
+ currentConfig = settings.get(NS) || {};
158
+ } else if (settings && typeof settings.describe === 'function') {
159
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
160
+ currentConfig = desc?.value || {};
161
+ } else {
162
+ currentConfig = deps.buildRuntime().cfg0 || {};
163
+ }
164
+ } catch (_) {
165
+ currentConfig = deps.buildRuntime().cfg0 || {};
166
+ }
167
+
168
+ const rawConfig = plainConfig(currentConfig);
169
+ const exportData = masked ? maskConfig(rawConfig) : rawConfig;
170
+
171
+ if (passphrase) {
172
+ const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
173
+ json(res, 200, {
174
+ ok: true,
175
+ version: '0.8.67',
176
+ exportedAt: new Date().toISOString(),
177
+ masked,
178
+ encrypted: true,
179
+ ...encryptedPayload,
180
+ });
181
+ return;
182
+ }
183
+
184
+ json(res, 200, {
185
+ ok: true,
186
+ version: '0.8.67',
187
+ exportedAt: new Date().toISOString(),
188
+ masked,
189
+ encrypted: false,
190
+ config: exportData,
191
+ });
192
+ },
193
+ }));
194
+
195
+ // ── POST /dsh-key-rotation/config-import ──
196
+ const handleImport = async (req, res) => {
197
+ if (req.method !== 'POST') {
198
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
199
+ return;
200
+ }
201
+ if (!isTrustedBridgeRequest(req)) {
202
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } });
203
+ return;
204
+ }
205
+
206
+ let body;
207
+ try {
208
+ body = await readJson(req);
209
+ } catch (e) {
210
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
211
+ return;
212
+ }
213
+
214
+ let incomingConfig = null;
215
+ const passphrase = body.passphrase || null;
216
+
217
+ if (body.encrypted && body.ciphertext) {
218
+ if (!passphrase) {
219
+ json(res, 400, { error: { code: 'passphrase-required', message: 'Passphrase is required for encrypted configuration' } });
220
+ return;
221
+ }
222
+ try {
223
+ const decryptedStr = decryptConfig(body, passphrase);
224
+ incomingConfig = JSON.parse(decryptedStr);
225
+ } catch (err) {
226
+ json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
227
+ return;
228
+ }
229
+ } else if (body.config) {
230
+ incomingConfig = body.config;
231
+ } else if (body.providers) {
232
+ incomingConfig = body;
233
+ } else {
234
+ json(res, 400, { error: { code: 'invalid-payload', message: 'Missing config or providers in request' } });
235
+ return;
236
+ }
237
+
238
+ if (!incomingConfig || typeof incomingConfig !== 'object') {
239
+ json(res, 400, { error: { code: 'invalid-config', message: 'Imported configuration must be an object' } });
240
+ return;
241
+ }
242
+
243
+ const mode = body.mode === 'replace' ? 'replace' : 'merge';
244
+ const dryRun = Boolean(body.dryRun);
245
+
246
+ const settings = ctx.get('settings');
247
+ let currentConfig = {};
248
+ try {
249
+ if (settings && typeof settings.get === 'function') {
250
+ currentConfig = settings.get(NS) || {};
251
+ } else if (settings && typeof settings.describe === 'function') {
252
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
253
+ currentConfig = desc?.value || {};
254
+ } else {
255
+ currentConfig = deps.buildRuntime().cfg0 || {};
256
+ }
257
+ } catch (_) {
258
+ currentConfig = deps.buildRuntime().cfg0 || {};
259
+ }
260
+ const rawCurrent = plainConfig(currentConfig);
261
+
262
+ const preview = diffConfigs(rawCurrent, incomingConfig, mode);
263
+
264
+ if (dryRun) {
265
+ json(res, 200, {
266
+ ok: true,
267
+ dryRun: true,
268
+ preview,
269
+ });
270
+ return;
271
+ }
272
+
273
+ // Apply configuration
274
+ let mergedConfig;
275
+ if (mode === 'replace') {
276
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
277
+ if (Array.isArray(incomingConfig.providers)) {
278
+ mergedConfig.providers = incomingConfig.providers;
279
+ }
280
+ } else {
281
+ // Merge mode
282
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
283
+ const curProviders = Array.isArray(rawCurrent.providers) ? [...rawCurrent.providers] : [];
284
+ const curMap = new Map();
285
+ for (let i = 0; i < curProviders.length; i++) {
286
+ const name = curProviders[i].provider || curProviders[i].name || curProviders[i].id;
287
+ if (name) curMap.set(name, i);
288
+ }
289
+ const inProviders = Array.isArray(incomingConfig.providers) ? incomingConfig.providers : [];
290
+ for (const p of inProviders) {
291
+ const name = p.provider || p.name || p.id;
292
+ if (!name) continue;
293
+ if (curMap.has(name)) {
294
+ const idx = curMap.get(name);
295
+ curProviders[idx] = { ...curProviders[idx], ...p };
296
+ } else {
297
+ curProviders.push(p);
298
+ }
299
+ }
300
+ mergedConfig.providers = curProviders;
301
+ }
302
+
303
+ if (settings && typeof settings.replace === 'function') {
304
+ try {
305
+ await settings.replace(NS, mergedConfig);
306
+ } catch (err) {
307
+ json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
308
+ return;
309
+ }
310
+ }
311
+
312
+ json(res, 200, {
313
+ ok: true,
314
+ applied: true,
315
+ mode,
316
+ preview,
317
+ });
318
+ };
319
+
320
+ ctx.effect(() => ctx.webServer.register({
321
+ kind: 'exact',
322
+ path: CONFIG_IMPORT_PATH,
323
+ handler: handleImport,
324
+ }));
325
+ }
package/lib/ops-paths.js CHANGED
@@ -19,3 +19,6 @@ export const DISCOVER_PATH = '/dsh-key-rotation/discover';
19
19
  export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
20
20
  export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
21
21
  export const BALANCE_PATH = '/dsh-key-rotation/balance';
22
+ export const CONFIG_EXPORT_PATH = '/dsh-key-rotation/export';
23
+ export const CONFIG_IMPORT_PATH = '/dsh-key-rotation/config-import';
24
+ export const QUICK_ACTION_PATH = '/dsh-key-rotation/quick-action';
@@ -0,0 +1,167 @@
1
+ import { json, readJson, isTrustedBridgeRequest } from './http-bridge.js';
2
+ import { QUICK_ACTION_PATH } from './ops-paths.js';
3
+ import { keyTail, isKeyPaused } from './pool.js';
4
+
5
+ /**
6
+ * @param {object} ctx cordis context
7
+ * @param {object} deps live dependencies from apply()
8
+ */
9
+ export function registerQuickActionRoutes(ctx, deps) {
10
+ const { buildRuntime, pushEvent, circuitBreaker } = deps;
11
+
12
+ ctx.effect(() => ctx.webServer.register({
13
+ kind: 'exact',
14
+ path: QUICK_ACTION_PATH,
15
+ handler: async (req, res) => {
16
+ if (req.method !== 'POST') {
17
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
18
+ return;
19
+ }
20
+ if (!isTrustedBridgeRequest(req)) {
21
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: quick-action is local-only' } });
22
+ return;
23
+ }
24
+
25
+ let body;
26
+ try {
27
+ body = await readJson(req);
28
+ } catch (e) {
29
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
30
+ return;
31
+ }
32
+
33
+ const action = typeof body?.action === 'string' ? body.action.trim().toLowerCase() : '';
34
+ const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
35
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : null;
36
+
37
+ if (!action || !['skip', 'reset', 'pause'].includes(action)) {
38
+ json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, or pause' } });
39
+ return;
40
+ }
41
+
42
+ if (!provider) {
43
+ json(res, 400, { error: { code: 'missing-provider', message: 'provider is required' } });
44
+ return;
45
+ }
46
+
47
+ const runtime = buildRuntime();
48
+ let targetPool = null;
49
+
50
+ const pools = runtime.pools ?? Array.from(runtime.poolByRef?.values() || []);
51
+ for (const pool of pools) {
52
+ if (pool.provider === provider || pool.base === provider) {
53
+ targetPool = pool;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (!targetPool) {
59
+ json(res, 404, { error: { code: 'pool-not-found', message: `Pool not found for provider: ${provider}` } });
60
+ return;
61
+ }
62
+
63
+ if (action === 'skip') {
64
+ const total = targetPool.refs?.length || 0;
65
+ if (total === 0) {
66
+ json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
67
+ return;
68
+ }
69
+ const currentCursor = targetPool.state.cursor ?? 0;
70
+ const nextCursor = (currentCursor + 1) % total;
71
+ targetPool.state.cursor = nextCursor;
72
+ const nextRef = targetPool.refs[nextCursor];
73
+ targetPool.state.lastUsed = nextRef;
74
+
75
+ if (typeof pushEvent === 'function') {
76
+ pushEvent(targetPool, nextRef, 'QUICK_ACTION_SKIP', 0);
77
+ }
78
+
79
+ json(res, 200, {
80
+ ok: true,
81
+ action: 'skip',
82
+ provider,
83
+ cursor: nextCursor,
84
+ activeRef: nextRef,
85
+ activeTail: keyTail(nextRef),
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (action === 'reset') {
91
+ targetPool.state.failedUntil?.clear();
92
+ targetPool.state.failCounts?.clear();
93
+ targetPool.state.resetReasons?.clear();
94
+ targetPool.state.headerResetTimes?.clear();
95
+ targetPool.state.switches = 0;
96
+ targetPool.state.lastReason = null;
97
+
98
+ const breaker = circuitBreaker ?? runtime.breaker;
99
+ if (breaker) {
100
+ if (typeof breaker.reset === 'function') breaker.reset(provider);
101
+ else if (typeof breaker.onSuccess === 'function') breaker.onSuccess(provider);
102
+ }
103
+
104
+ if (typeof pushEvent === 'function') {
105
+ pushEvent(targetPool, '*', 'QUICK_ACTION_RESET', 0);
106
+ }
107
+
108
+ json(res, 200, {
109
+ ok: true,
110
+ action: 'reset',
111
+ provider,
112
+ healthyKeys: targetPool.refs?.length || 0,
113
+ });
114
+ return;
115
+ }
116
+
117
+ if (action === 'pause') {
118
+ const targetRef = ref || targetPool.state.lastUsed || targetPool.refs?.[0];
119
+ if (!targetRef) {
120
+ json(res, 400, { error: { code: 'no-ref', message: 'No key specified or active in pool' } });
121
+ return;
122
+ }
123
+
124
+ let isPaused = false;
125
+ if (!targetPool.state.pausedRefs) targetPool.state.pausedRefs = new Set();
126
+
127
+ if (targetPool.state.pausedRefs instanceof Set) {
128
+ if (targetPool.state.pausedRefs.has(targetRef)) {
129
+ targetPool.state.pausedRefs.delete(targetRef);
130
+ targetPool.state.pauseReasons?.delete(targetRef);
131
+ isPaused = false;
132
+ } else {
133
+ targetPool.state.pausedRefs.add(targetRef);
134
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
135
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
136
+ isPaused = true;
137
+ }
138
+ } else if (Array.isArray(targetPool.state.pausedRefs)) {
139
+ const idx = targetPool.state.pausedRefs.indexOf(targetRef);
140
+ if (idx >= 0) {
141
+ targetPool.state.pausedRefs.splice(idx, 1);
142
+ targetPool.state.pauseReasons?.delete(targetRef);
143
+ isPaused = false;
144
+ } else {
145
+ targetPool.state.pausedRefs.push(targetRef);
146
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
147
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
148
+ isPaused = true;
149
+ }
150
+ }
151
+
152
+ if (typeof pushEvent === 'function') {
153
+ pushEvent(targetPool, targetRef, isPaused ? 'KEY_PAUSED' : 'KEY_RESUMED', 0);
154
+ }
155
+
156
+ json(res, 200, {
157
+ ok: true,
158
+ action: 'pause',
159
+ provider,
160
+ ref: targetRef,
161
+ paused: isPaused,
162
+ });
163
+ return;
164
+ }
165
+ },
166
+ }));
167
+ }
package/lib/ops-status.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  import { bucketInfo, tpmInfo } from './bucket.js';
18
18
  import { isBlockedUntilReset } from './quota-window.js';
19
19
  import { sanitizeSnapshot } from './sanitize-snapshot.js';
20
+ import { compute24hAnalytics } from './ops-analytics.js';
21
+ import { getPromptCacheSummary } from './prompt-cache.js';
20
22
  import {
21
23
  STATUS_PATH,
22
24
  HEALTH_PATH,
@@ -177,6 +179,8 @@ ctx.effect(() => ctx.webServer.register({
177
179
  routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
178
180
  proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
179
181
  smoothedRequests: pool.state.smoothedRequests ?? 0,
182
+ promptCache: getPromptCacheSummary(pool, now),
183
+ analytics24h: compute24hAnalytics(pool, now),
180
184
  });
181
185
  } catch (e) {
182
186
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
@@ -242,6 +246,8 @@ ctx.effect(() => ctx.webServer.register({
242
246
  providerName: pool.provider ?? providerOfBase(pool.base),
243
247
  basePool: basePoolH?.base ?? null,
244
248
  modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
249
+ lastUsed: pool.state.lastUsed ?? null,
250
+ lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
245
251
  };
246
252
  }
247
253
  let circuitOpenAny = false;