@goodandready/dsh-key-rotation 0.8.78 → 0.8.79

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.
@@ -1,168 +1,2 @@
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 currentPointer = targetPool.state.pointer ?? targetPool.state.cursor ?? 0;
70
- const nextPointer = (currentPointer + 1) % total;
71
- targetPool.state.pointer = nextPointer;
72
- targetPool.state.cursor = nextPointer;
73
- const nextRef = targetPool.refs[nextPointer];
74
- targetPool.state.lastUsed = nextRef;
75
-
76
- if (typeof pushEvent === 'function') {
77
- pushEvent(targetPool, nextRef, 'QUICK_ACTION_SKIP', 0);
78
- }
79
-
80
- json(res, 200, {
81
- ok: true,
82
- action: 'skip',
83
- provider,
84
- cursor: nextPointer,
85
- activeRef: nextRef,
86
- activeTail: keyTail(nextRef),
87
- });
88
- return;
89
- }
90
-
91
- if (action === 'reset') {
92
- targetPool.state.failedUntil?.clear();
93
- targetPool.state.failCounts?.clear();
94
- targetPool.state.resetReasons?.clear();
95
- targetPool.state.headerResetTimes?.clear();
96
- targetPool.state.switches = 0;
97
- targetPool.state.lastReason = null;
98
-
99
- const breaker = circuitBreaker ?? runtime.breaker;
100
- if (breaker) {
101
- if (typeof breaker.reset === 'function') breaker.reset(provider);
102
- else if (typeof breaker.onSuccess === 'function') breaker.onSuccess(provider);
103
- }
104
-
105
- if (typeof pushEvent === 'function') {
106
- pushEvent(targetPool, '*', 'QUICK_ACTION_RESET', 0);
107
- }
108
-
109
- json(res, 200, {
110
- ok: true,
111
- action: 'reset',
112
- provider,
113
- healthyKeys: targetPool.refs?.length || 0,
114
- });
115
- return;
116
- }
117
-
118
- if (action === 'pause') {
119
- const targetRef = ref || targetPool.state.lastUsed || targetPool.refs?.[0];
120
- if (!targetRef) {
121
- json(res, 400, { error: { code: 'no-ref', message: 'No key specified or active in pool' } });
122
- return;
123
- }
124
-
125
- let isPaused = false;
126
- if (!targetPool.state.pausedRefs) targetPool.state.pausedRefs = new Set();
127
-
128
- if (targetPool.state.pausedRefs instanceof Set) {
129
- if (targetPool.state.pausedRefs.has(targetRef)) {
130
- targetPool.state.pausedRefs.delete(targetRef);
131
- targetPool.state.pauseReasons?.delete(targetRef);
132
- isPaused = false;
133
- } else {
134
- targetPool.state.pausedRefs.add(targetRef);
135
- if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
136
- targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
137
- isPaused = true;
138
- }
139
- } else if (Array.isArray(targetPool.state.pausedRefs)) {
140
- const idx = targetPool.state.pausedRefs.indexOf(targetRef);
141
- if (idx >= 0) {
142
- targetPool.state.pausedRefs.splice(idx, 1);
143
- targetPool.state.pauseReasons?.delete(targetRef);
144
- isPaused = false;
145
- } else {
146
- targetPool.state.pausedRefs.push(targetRef);
147
- if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
148
- targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
149
- isPaused = true;
150
- }
151
- }
152
-
153
- if (typeof pushEvent === 'function') {
154
- pushEvent(targetPool, targetRef, isPaused ? 'KEY_PAUSED' : 'KEY_RESUMED', 0);
155
- }
156
-
157
- json(res, 200, {
158
- ok: true,
159
- action: 'pause',
160
- provider,
161
- ref: targetRef,
162
- paused: isPaused,
163
- });
164
- return;
165
- }
166
- },
167
- }));
168
- }
1
+ // lib/ops-quick-action.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/quick-action.js";
package/lib/ops-status.js CHANGED
@@ -1,269 +1,2 @@
1
- import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
2
- import { providerOfBase, basePoolForBase, modelPoolsForRef } from './pool-index.js';
3
- import { decryptSecret } from './crypto-storage.js';
4
- // lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
5
- import { json } from './http-bridge.js';
6
- import {
7
- isTrustedBridgeRequest,
8
- isKeyPaused,
9
- isKeyRevoked,
10
- keyTail,
11
- envValue,
12
- computeHealthScore,
13
- costForDay,
14
- costForWeek,
15
- isLoopbackAddress,
16
- } from './pool.js';
17
- import { bucketInfo, tpmInfo } from './bucket.js';
18
- import { isBlockedUntilReset } from './quota-window.js';
19
- import { sanitizeSnapshot } from './sanitize-snapshot.js';
20
- import { compute24hAnalytics } from './ops-analytics.js';
21
- import { getPromptCacheSummary } from './prompt-cache.js';
22
- import {
23
- STATUS_PATH,
24
- HEALTH_PATH,
25
- } from './ops-paths.js';
26
-
27
- /**
28
- * @param {object} ctx cordis context
29
- * @param {object} deps live dependencies from apply()
30
- */
31
- export function registerStatusRoutes(ctx, deps) {
32
- const {
33
- buildRuntime,
34
- latencyHistogram,
35
- circuitBreaker,
36
- notifyQueue,
37
- quotaStore,
38
- } = deps;
39
-
40
- // ── status route: what the settings card cannot know on its own ──
41
- //
42
- // Reports, per configured provider, which key is in use, which are cooling
43
- // down and until when, whether an env name resolves to a credential at all
44
- // (a typo is otherwise silent), and how often rotation has fired.
45
- //
46
- // Key VALUES never leave the host — only the boolean fact that one exists.
47
- ctx.effect(() => ctx.webServer.register({
48
- kind: 'exact',
49
- path: STATUS_PATH,
50
- handler: async (req, res) => {
51
- if (req.method !== 'GET') {
52
- json(res, 405, { error: { code: 'method', message: 'GET only' } });
53
- return;
54
- }
55
- if (!isTrustedBridgeRequest(req)) {
56
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
57
- return;
58
- }
59
- const runtime = buildRuntime();
60
- const { poolByRef, providerTags, providerBudgets, latencySloMs } = runtime;
61
- const base = ctx.get('credentials');
62
- const now = Date.now();
63
- const seen = new Set();
64
- const providers = [];
65
- for (const pool of (runtime.pools ?? poolByRef.values())) {
66
- if (seen.has(pool.base)) continue;
67
- seen.add(pool.base);
68
- try {
69
- const keys = [];
70
- for (const ref of pool.refs) {
71
- let present = false;
72
- let tail = '';
73
- let source = null;
74
- let writable = true;
75
- try {
76
- // The resolve patch is installed on this same service, so ask for
77
- // the exact ref: a pool ref would otherwise round-robin to another
78
- // key and report a missing name as present.
79
- let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
80
- present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
81
- if (present) tail = keyTail(hit.value);
82
- // fallback: env var bootstrapping (issue #7)
83
- if (!present) {
84
- const ev = envValue(ref);
85
- if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
86
- }
87
- } catch {
88
- present = false;
89
- }
90
- try {
91
- const described = await base?.describe?.(ref);
92
- source = described?.source ?? null;
93
- writable = described?.writable !== false;
94
- } catch {
95
- /* describe is optional — the card falls back to editable */
96
- }
97
- const until = pool.state.failedUntil?.get(ref);
98
- const quotaWin = pool.state.quotaWindows?.get(ref);
99
- const modelQuota = getModelQuotaStatus(pool, ref, now);
100
- const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
101
- keys.push({
102
- ref,
103
- present,
104
- tail,
105
- source,
106
- writable,
107
- active: pool.state.lastUsed === ref,
108
- cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
109
- blockedUntilReset: isBlockedUntilReset(until, quotaWin, now),
110
- // #210: RPM capacity snapshot (null when rpmLimit is off)
111
- rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
112
- // TPM capacity snapshot (null when tpmLimit is off)
113
- tpm: tpmInfo(pool.state.tpmWindows, ref, pool.tpmLimit, now),
114
- // #215: effective round-robin weight of this key
115
- weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
116
- usage: pool.state.usageCounts?.get(ref) ?? 0,
117
- byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
118
- usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
119
- cost: pool.state.costPerKey?.get(ref) ?? 0,
120
- lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
121
- expiresAt: pool.expiresAt?.[ref] ?? null,
122
- expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
123
- broken: pool.state.brokenUntil?.has(ref) ?? false,
124
- paused: isKeyPaused(pool, ref),
125
- pauseReason: pool.state.pauseReasons?.get?.(ref) ?? null,
126
- revoked: isKeyRevoked(pool, ref),
127
- modelCapabilities: pool.state.modelCapabilities?.get?.(ref) ? [...pool.state.modelCapabilities.get(ref)] : null,
128
- detectedLimits: pool.state.detectedLimits?.get?.(ref) ?? null,
129
- balance: pool.state.balances?.get?.(ref) ?? null,
130
- resetReason: pool.state.resetReasons?.get?.(ref) ?? null,
131
- headerResetMs: pool.state.headerResetTimes?.get?.(ref) ?? null,
132
- modelQuota,
133
- modelPools: activeModelPools.map(mp => mp.base),
134
- });
135
- }
136
- const basePool = pool.basePool ?? (runtime.index ? basePoolForBase(runtime.index, pool.base) : null);
137
- providers.push({
138
- provider: pool.base,
139
- providerName: pool.provider ?? providerOfBase(pool.base),
140
- basePool: basePool?.base ?? null,
141
- model: pool.model ?? null,
142
- pinnedRef: pool.pinnedRef ?? null,
143
- labels: pool.labels ?? {},
144
- quotasConfigured: hasModelQuotaConfig(pool),
145
- keys,
146
- tags: providerTags?.get?.(pool.base) ?? [],
147
- // #260 circuit breaker state (may be null if not yet tripped)
148
- circuit: (() => {
149
- const br = circuitBreaker ?? runtime.breaker;
150
- if (!br) return null;
151
- let st = br.state(pool.base);
152
- const provKey = pool.provider ?? providerOfBase(pool.base);
153
- if (st === 'closed' && provKey && provKey !== pool.base) {
154
- const baseSt = br.state(provKey);
155
- if (baseSt !== 'closed') st = baseSt;
156
- }
157
- return { state: st, threshold: br.threshold, openMs: br.openMs };
158
- })(),
159
- switches: pool.state.switches ?? 0,
160
- lastReason: pool.state.lastReason ?? null,
161
- lastSwitchAt: pool.state.lastSwitchAt ?? null,
162
- lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
163
- exhaustionCount: pool.state.exhaustionCount ?? 0,
164
- totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
165
- // #225: aggregate p95 across the pool's keys
166
- p95: (() => {
167
- const vals = (pool.refs ?? []).map((r) => (typeof latencyHistogram?.snapshot === 'function' ? latencyHistogram.snapshot(r) : null)).filter((s) => s && s.p95 != null).map((s) => s.p95);
168
- return vals.length ? Math.round(Math.max(...vals)) : null;
169
- })(),
170
- latencySloMs,
171
- events: (pool.state.events ?? []).slice(-50),
172
- healthScore: computeHealthScore(pool.state),
173
- // #208: today/week spend + configured budget for the card
174
- todayCost: costForDay(pool.state.costDays),
175
- weeklyCost: costForWeek(pool.state.costDays, now),
176
- budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
177
- budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
178
- pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
179
- routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
180
- proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
181
- smoothedRequests: pool.state.smoothedRequests ?? 0,
182
- promptCache: getPromptCacheSummary(pool, now),
183
- analytics24h: compute24hAnalytics(pool, now),
184
- });
185
- } catch (e) {
186
- (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
187
- providers.push({ provider: pool.base, model: pool.model ?? null, keys: [], statusError: String(e?.message ?? e) });
188
- }
189
- }
190
- json(res, 200, sanitizeSnapshot({
191
- providers,
192
- showChatHeaderChip: runtime.showChatHeaderChip !== false,
193
- // #266/#263 operational extras (additive)
194
- meta: {
195
- expectedClones: [...(runtime.expectedClones ?? [])],
196
- notifyQueue: (notifyQueue ?? runtime.notifyQueue)?.stats?.() ?? (notifyQueue ?? runtime.notifyQueue)?.snapshot?.() ?? null,
197
- breakerEnabled: runtime.circuitBreakerEnabled !== false,
198
- at: now,
199
- },
200
- }, now));
201
- },
202
- }), 'dsh-key-rotation: status route');
203
-
204
- // #209: usage report - per-key requests/cost over the last N days.
205
- // ?format=csv returns text/csv; ?days=N window (1..90, default 7).
206
-
207
- ctx.effect(() => ctx.webServer.register({
208
- kind: 'exact',
209
- path: HEALTH_PATH,
210
- handler: async (req, res) => {
211
- if (!isTrustedBridgeRequest(req)) {
212
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: health is local-only' } });
213
- return;
214
- }
215
- if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
216
- const now = Date.now();
217
- const pools = {};
218
- let exhaustedAny = false;
219
- const runtimeH = buildRuntime();
220
- const seenH = new Set();
221
- for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
222
- if (seenH.has(pool.base)) continue;
223
- seenH.add(pool.base);
224
- const quotaAware = hasModelQuotaConfig(pool);
225
- let healthy = 0;
226
- let quotaExhausted = 0;
227
- for (const ref of pool.refs) {
228
- if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
229
- const until = pool.state.failedUntil?.get(ref);
230
- if (until !== undefined && until > now) continue;
231
- const exp = pool.expiresAt?.[ref];
232
- if (exp !== undefined && now >= exp) continue;
233
- if (quotaAware && !isModelQuotaAvailable(pool, ref, now)) { quotaExhausted++; continue; }
234
- healthy++;
235
- }
236
- const total = pool.refs.length;
237
- const exhausted = healthy === 0 && total > 0;
238
- if (exhausted) exhaustedAny = true;
239
- const basePoolH = pool.basePool ?? (runtimeH.index ? basePoolForBase(runtimeH.index, pool.base) : null);
240
- pools[pool.base] = {
241
- healthy,
242
- total,
243
- exhausted,
244
- healthScore: computeHealthScore(pool.state),
245
- model: pool.model ?? null,
246
- providerName: pool.provider ?? providerOfBase(pool.base),
247
- basePool: basePoolH?.base ?? null,
248
- modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
249
- lastUsed: pool.state.lastUsed ?? null,
250
- lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
251
- };
252
- }
253
- let circuitOpenAny = false;
254
- const brH = circuitBreaker ?? runtimeH.breaker;
255
- if (brH) {
256
- for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
257
- const provKey = pool.provider ?? providerOfBase(pool.base);
258
- if (brH.state(pool.base) === 'open' || brH.state(provKey) === 'open') {
259
- circuitOpenAny = true;
260
- break;
261
- }
262
- }
263
- }
264
- const isDegraded = exhaustedAny || circuitOpenAny;
265
- json(res, 200, { status: isDegraded ? 'degraded' : 'ok', showChatHeaderChip: runtimeH.showChatHeaderChip !== false, pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
266
- },
267
- }), 'dsh-key-rotation: health');
268
-
269
- }
1
+ // lib/ops-status.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/status.js";