@goodandready/dsh-key-rotation 0.8.53 → 0.8.54

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.54 - 2026-10-11
4
+
5
+ ### Added
6
+ - **Shared Request Deadline and Attempt Budget Across Rotation & Cascade (#464)**:
7
+ - Added `createRequestBudget` in `lib/request-budget.js` with monotonic timer (`nowMono`) and `AbortSignal` merging.
8
+ - Added `maxDurationMs` and `maxAttempts` configuration options.
9
+ - Budget threads through rotation and cross-provider cascade recursively without resetting counters, terminating early with `BUDGET_DEADLINE_EXCEEDED` or `BUDGET_ATTEMPTS_EXCEEDED`.
10
+ - **Key Selection Explanation & Request Attempt Trace (#461)**:
11
+ - Added `createSelectionTrace` in `lib/selection-trace.js` tracking candidate evaluation decisions, cooldown reasons, and dispatches.
12
+ - Added loopback-guarded endpoints `GET /dsh-key-rotation/trace` and `DELETE /dsh-key-rotation/trace` in `lib/ops-telemetry.js` with automatic secret redaction.
13
+ - **Offline Rotation & Cascade Simulator (#462)**:
14
+ - Added `simulateRouting` engine in `lib/simulator.js` running over isolated in-memory state without HTTP requests, token charges, or credential resolution.
15
+ - Added `POST /dsh-key-rotation/simulate` endpoint in `lib/ops-telemetry.js` to preview failover decisions.
16
+ - **Managed Key Draining and Graceful Replacement (#463)**:
17
+ - Added `lib/key-drain.js` tracking draining keys and in-flight stream concurrency.
18
+ - `rotate.js` excludes draining keys from new candidate dispatches while active in-flight streams finish cleanly.
19
+ - Added `GET / POST / DELETE /dsh-key-rotation/drain` endpoints in `lib/ops-keys.js`.
20
+
3
21
  ## 0.8.53 - 2026-10-09
4
22
 
5
23
  ### Fixed
package/lib/index.js CHANGED
@@ -105,6 +105,8 @@ const SettingsSchema = Schema.object({
105
105
  circuitBreakerThreshold: Schema.number().default(5),
106
106
  circuitBreakerOpenMs: Schema.number().default(30000),
107
107
  circuitBreakerHalfOpenProbes: Schema.number().default(1),
108
+ maxDurationMs: Schema.number().default(0),
109
+ maxAttempts: Schema.number().default(0),
108
110
  persistenceEnabled: Schema.boolean().default(true),
109
111
  persistencePath: Schema.string().default(''),
110
112
  switchNotifyThrottleMs: Schema.number().default(60000),
@@ -0,0 +1,54 @@
1
+ // lib/key-drain.js — Managed key draining and graceful replacement (#463).
2
+ // Allows draining an old key ref (no new dispatches assigned) while letting
3
+ // in-flight active streams complete cleanly before permanent removal.
4
+
5
+ const drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
6
+
7
+ export function markKeyDraining(ref, provider = '') {
8
+ if (!ref) return false;
9
+ drainingKeys.set(ref, {
10
+ ref,
11
+ provider,
12
+ startedAt: Date.now(),
13
+ });
14
+ return true;
15
+ }
16
+
17
+ export function isKeyDraining(ref) {
18
+ if (!ref) return false;
19
+ return drainingKeys.has(ref);
20
+ }
21
+
22
+ export function cancelKeyDrain(ref) {
23
+ if (!ref) return false;
24
+ return drainingKeys.delete(ref);
25
+ }
26
+
27
+ export function getDrainingStatus(ref, concurrencyTracker) {
28
+ if (!ref || !drainingKeys.has(ref)) return null;
29
+ const entry = drainingKeys.get(ref);
30
+ const inFlight = concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
31
+ ? concurrencyTracker.getInFlight(ref)
32
+ : 0;
33
+
34
+ return {
35
+ ref,
36
+ provider: entry.provider,
37
+ startedAt: entry.startedAt,
38
+ elapsedMs: Date.now() - entry.startedAt,
39
+ inFlight,
40
+ canComplete: inFlight === 0,
41
+ };
42
+ }
43
+
44
+ export function getAllDrainingKeys(concurrencyTracker) {
45
+ const result = [];
46
+ for (const ref of drainingKeys.keys()) {
47
+ result.push(getDrainingStatus(ref, concurrencyTracker));
48
+ }
49
+ return result;
50
+ }
51
+
52
+ export function clearAllDraining() {
53
+ drainingKeys.clear();
54
+ }
package/lib/ops-keys.js CHANGED
@@ -19,13 +19,64 @@ import {
19
19
  KEY_PATH,
20
20
  RESET_PATH,
21
21
  IMPORT_PATH,
22
+ DRAIN_PATH,
22
23
  } from './ops-paths.js';
24
+ import { markKeyDraining, isKeyDraining, cancelKeyDrain, getDrainingStatus, getAllDrainingKeys } from './key-drain.js';
23
25
 
24
26
  /**
25
27
  * @param {object} ctx cordis context
26
28
  * @param {object} deps live dependencies from apply()
27
29
  */
28
30
  export function registerKeyRoutes(ctx, deps) {
31
+
32
+ ctx.effect(() => ctx.webServer.register({
33
+ kind: 'exact',
34
+ path: DRAIN_PATH,
35
+ handler: async (req, res) => {
36
+ if (!isTrustedBridgeRequest(req)) {
37
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: drain is local-only' } });
38
+ return;
39
+ }
40
+ const concurrency = deps.concurrencyTracker;
41
+
42
+ if (req.method === 'GET') {
43
+ const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
44
+ const ref = url.searchParams.get('ref');
45
+ if (ref) {
46
+ const status = getDrainingStatus(ref, concurrency);
47
+ if (!status) { json(res, 404, { error: { code: 'not-found', message: `Key ${ref} is not currently draining` } }); return; }
48
+ json(res, 200, { ok: true, draining: status });
49
+ return;
50
+ }
51
+ json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency) });
52
+ return;
53
+ }
54
+
55
+ if (req.method === 'POST') {
56
+ let body;
57
+ try { body = await readJson(req); } catch (e) {
58
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
59
+ return;
60
+ }
61
+ const ref = body?.ref;
62
+ if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref' } }); return; }
63
+ markKeyDraining(ref, body?.provider || '');
64
+ json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency) });
65
+ return;
66
+ }
67
+
68
+ if (req.method === 'DELETE') {
69
+ const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
70
+ const ref = url.searchParams.get('ref');
71
+ if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref query parameter' } }); return; }
72
+ const cancelled = cancelKeyDrain(ref);
73
+ json(res, 200, { ok: true, cancelled });
74
+ return;
75
+ }
76
+
77
+ json(res, 405, { error: { code: 'method', message: 'GET, POST, or DELETE only' } });
78
+ },
79
+ }), 'dsh-key-rotation: drain route');
29
80
  const {
30
81
  lastTestCache,
31
82
  poolState,
package/lib/ops-paths.js CHANGED
@@ -11,3 +11,6 @@ export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
11
11
  export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
12
12
  export const METRICS_PATH = '/dsh-key-rotation/metrics';
13
13
  export const DIAG_PATH = '/dsh-key-rotation/diag';
14
+ export const TRACE_PATH = '/dsh-key-rotation/trace';
15
+ export const SIMULATE_PATH = '/dsh-key-rotation/simulate';
16
+ export const DRAIN_PATH = '/dsh-key-rotation/drain';
@@ -14,7 +14,11 @@ import {
14
14
  USAGE_PATH,
15
15
  SNAPSHOT_PATH,
16
16
  DIAG_PATH,
17
+ TRACE_PATH,
18
+ SIMULATE_PATH,
17
19
  } from './ops-paths.js';
20
+ import { simulateRouting } from './simulator.js';
21
+ import { getSelectionTraces, clearSelectionTraces } from './selection-trace.js';
18
22
  import { findSecrets } from './keycheck.js';
19
23
  import { mutateSettings, sectionOps } from './settings-write.js';
20
24
 
@@ -130,6 +134,73 @@ ctx.effect(() => ctx.webServer.register({
130
134
  },
131
135
  }), 'dsh-key-rotation: snapshot route');
132
136
 
137
+
138
+ ctx.effect(() => ctx.webServer.register({
139
+ kind: 'exact',
140
+ path: TRACE_PATH,
141
+ handler: (req, res) => {
142
+ if (req.method !== 'GET' && req.method !== 'DELETE') {
143
+ json(res, 405, { error: { code: 'method', message: 'GET or DELETE only' } });
144
+ return;
145
+ }
146
+ if (!isTrustedBridgeRequest(req)) {
147
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
148
+ return;
149
+ }
150
+ if (req.method === 'DELETE') {
151
+ clearSelectionTraces();
152
+ json(res, 200, { ok: true, message: 'trace history cleared' });
153
+ return;
154
+ }
155
+ const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
156
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
157
+ json(res, 200, { ok: true, traces: getSelectionTraces(limit) });
158
+ },
159
+ }), 'dsh-key-rotation: trace route');
160
+
161
+
162
+ ctx.effect(() => ctx.webServer.register({
163
+ kind: 'exact',
164
+ path: SIMULATE_PATH,
165
+ handler: async (req, res) => {
166
+ if (req.method !== 'POST') {
167
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
168
+ return;
169
+ }
170
+ if (!isTrustedBridgeRequest(req)) {
171
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: simulate is local-only' } });
172
+ return;
173
+ }
174
+ let body;
175
+ try { body = await readJson(req); } catch (e) {
176
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
177
+ return;
178
+ }
179
+
180
+ const runtime = buildRuntime();
181
+ const provider = body?.provider || (runtime.pools?.[0]?.base ?? '');
182
+ const pool = runtime.providerToPool?.[provider] || runtime.pools?.[0];
183
+
184
+ if (!pool) {
185
+ json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found` } });
186
+ return;
187
+ }
188
+
189
+ try {
190
+ const result = simulateRouting({
191
+ pool,
192
+ runtime,
193
+ events: Array.isArray(body?.events) ? body.events : [],
194
+ model: body?.model || '',
195
+ maxAttempts: Number(body?.maxAttempts) || 0,
196
+ });
197
+ json(res, 200, { ok: true, simulation: result });
198
+ } catch (e) {
199
+ json(res, 500, { error: { code: 'sim-error', message: String(e?.message ?? e) } });
200
+ }
201
+ },
202
+ }), 'dsh-key-rotation: simulate route');
203
+
133
204
  ctx.effect(() => ctx.webServer.register({
134
205
  kind: 'exact',
135
206
  path: DIAG_PATH,
@@ -0,0 +1,112 @@
1
+ // lib/request-budget.js — per-request deadline & attempt budget management (#464).
2
+ // Monotonic clock timing via nowMono(), abort signal chaining, and dispatch attempt tracking.
3
+ import { nowMono } from './clock.js';
4
+
5
+ export const BUDGET_EXHAUSTED_TIMEOUT = 'BUDGET_DEADLINE_EXCEEDED';
6
+ export const BUDGET_EXHAUSTED_ATTEMPTS = 'BUDGET_ATTEMPTS_EXCEEDED';
7
+
8
+ /**
9
+ * Creates or normalizes a RequestBudget for rotation and cascade.
10
+ * @param {object} [options]
11
+ * @param {number} [options.maxDurationMs] - Max total wall duration in ms (0 = unlimited).
12
+ * @param {number} [options.maxAttempts] - Max total upstream dispatch attempts (0 = unlimited).
13
+ * @param {AbortSignal} [options.signal] - Caller-provided abort signal.
14
+ * @returns {object}
15
+ */
16
+ export function createRequestBudget(options = {}) {
17
+ const maxDurationMs = Math.max(0, Number(options.maxDurationMs) || 0);
18
+ const maxAttempts = Math.max(0, Number(options.maxAttempts) || 0);
19
+ const startTime = nowMono();
20
+ const deadline = maxDurationMs > 0 ? startTime + maxDurationMs : Infinity;
21
+ let attempts = 0;
22
+
23
+ const controller = new AbortController();
24
+ let timerId = null;
25
+
26
+ // Chain caller signal if provided
27
+ if (options.signal) {
28
+ if (options.signal.aborted) {
29
+ controller.abort(options.signal.reason);
30
+ } else {
31
+ const onCallerAbort = () => {
32
+ controller.abort(options.signal.reason);
33
+ cleanup();
34
+ };
35
+ options.signal.addEventListener('abort', onCallerAbort, { once: true });
36
+ }
37
+ }
38
+
39
+ // Setup deadline timeout if finite
40
+ if (Number.isFinite(deadline)) {
41
+ const remaining = Math.max(0, deadline - startTime);
42
+ timerId = setTimeout(() => {
43
+ const err = new Error(`Request exceeded maximum deadline of ${maxDurationMs}ms`);
44
+ err.code = BUDGET_EXHAUSTED_TIMEOUT;
45
+ controller.abort(err);
46
+ }, remaining);
47
+ if (timerId.unref) timerId.unref();
48
+ }
49
+
50
+ function cleanup() {
51
+ if (timerId) {
52
+ clearTimeout(timerId);
53
+ timerId = null;
54
+ }
55
+ }
56
+
57
+ return {
58
+ startTime,
59
+ maxDurationMs,
60
+ maxAttempts,
61
+ deadline,
62
+ signal: controller.signal,
63
+
64
+ /** Check if deadline is expired */
65
+ isExpired() {
66
+ if (controller.signal.aborted) return true;
67
+ if (Number.isFinite(deadline) && nowMono() >= deadline) {
68
+ return true;
69
+ }
70
+ return false;
71
+ },
72
+
73
+ /** Remaining time in ms before deadline (or Infinity if none) */
74
+ remainingMs() {
75
+ if (controller.signal.aborted) return 0;
76
+ if (!Number.isFinite(deadline)) return Infinity;
77
+ return Math.max(0, Math.round(deadline - nowMono()));
78
+ },
79
+
80
+ /** Current count of attempted dispatches */
81
+ getAttempts() {
82
+ return attempts;
83
+ },
84
+
85
+ /** Check whether another attempt is permitted */
86
+ canAttempt() {
87
+ if (this.isExpired()) return false;
88
+ if (maxAttempts > 0 && attempts >= maxAttempts) return false;
89
+ return true;
90
+ },
91
+
92
+ /** Record a real upstream dispatch attempt */
93
+ recordAttempt() {
94
+ if (!this.canAttempt()) {
95
+ const err = new Error(
96
+ this.isExpired()
97
+ ? `Request exceeded maximum duration (${maxDurationMs}ms)`
98
+ : `Request exceeded maximum attempts (${maxAttempts})`
99
+ );
100
+ err.code = this.isExpired() ? BUDGET_EXHAUSTED_TIMEOUT : BUDGET_EXHAUSTED_ATTEMPTS;
101
+ throw err;
102
+ }
103
+ attempts += 1;
104
+ return attempts;
105
+ },
106
+
107
+ /** Cleanup timers / listeners */
108
+ dispose() {
109
+ cleanup();
110
+ }
111
+ };
112
+ }
package/lib/rotate.js CHANGED
@@ -16,6 +16,9 @@ import {
16
16
  sortAttemptList,
17
17
  } from './pool.js';
18
18
  import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
19
+ import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
20
+ import { createSelectionTrace } from './selection-trace.js';
21
+ import { isKeyDraining } from './key-drain.js';
19
22
  import { nextQuotaReset, poolResetAt } from './quota-window.js';
20
23
  import { tpmRecord } from './bucket.js';
21
24
  import {
@@ -79,6 +82,21 @@ export function createRotate(deps) {
79
82
  function rotate(options, pool) {
80
83
  return (async function* () {
81
84
  const runtime0 = buildRuntime();
85
+ // #464: Per-request budget (deadline and max attempts) shared across rotation and cascade
86
+ const existingBudget = options.__requestBudget;
87
+ const budget = existingBudget || createRequestBudget({
88
+ maxDurationMs: options.maxDurationMs ?? pool.maxDurationMs ?? runtime0.maxDurationMs ?? 0,
89
+ maxAttempts: options.maxAttempts ?? pool.maxAttempts ?? runtime0.maxAttempts ?? 0,
90
+ signal: options.signal,
91
+ });
92
+ const isBudgetOwner = !existingBudget;
93
+
94
+ // #461: Track key selection and failover explanation
95
+ const trace = options.__selectionTrace || createSelectionTrace(options.requestId, {
96
+ provider: options.provider,
97
+ model: options.model,
98
+ });
99
+ const isTraceOwner = !options.__selectionTrace;
82
100
  const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
83
101
  let lastFailure = null;
84
102
  const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
@@ -88,7 +106,7 @@ export function createRotate(deps) {
88
106
  const wallNow = Date.now();
89
107
  const quotaManaged = hasModelQuotaConfig(pool);
90
108
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
91
- attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow));
109
+ attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow) && !isKeyDraining(r));
92
110
  if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
93
111
  const pIdx = attemptList.indexOf(pool.pinnedRef);
94
112
  if (pIdx > 0) {
@@ -119,6 +137,8 @@ export function createRotate(deps) {
119
137
  model: fb.model || options.model,
120
138
  __isCascade: true,
121
139
  __cascadeDepth: currentDepth + 1,
140
+ __requestBudget: budget,
141
+ __selectionTrace: trace,
122
142
  }, fb.pool);
123
143
  for await (const chunk of innerCascade) {
124
144
  yield chunk;
@@ -179,6 +199,7 @@ export function createRotate(deps) {
179
199
  }
180
200
 
181
201
  activePermit?.failure();
202
+ trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
182
203
  pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
183
204
  if (typeof schedulePersist === 'function') {
184
205
  try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
@@ -235,6 +256,19 @@ export function createRotate(deps) {
235
256
  };
236
257
 
237
258
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
259
+ // #464: Check deadline & attempt budget before dispatch
260
+ if (!budget.canAttempt()) {
261
+ const isTimeout = budget.isExpired();
262
+ const code = isTimeout ? BUDGET_EXHAUSTED_TIMEOUT : BUDGET_EXHAUSTED_ATTEMPTS;
263
+ const msg = isTimeout
264
+ ? `[dsh-key-rotation] request deadline exceeded (${budget.maxDurationMs}ms)`
265
+ : `[dsh-key-rotation] request attempt budget exceeded (${budget.maxAttempts} attempts)`;
266
+ logWarn(msg);
267
+ yield finishError(code, msg);
268
+ if (isBudgetOwner) budget.dispose();
269
+ return;
270
+ }
271
+
238
272
  activePermit = breaker ? breaker.acquire(options.provider) : null;
239
273
  if (breaker && !activePermit) {
240
274
  logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
@@ -254,6 +288,8 @@ export function createRotate(deps) {
254
288
  model: fb.model || options.model,
255
289
  __isCascade: true,
256
290
  __cascadeDepth: currentDepth + 1,
291
+ __requestBudget: budget,
292
+ __selectionTrace: trace,
257
293
  }, fb.pool);
258
294
  for await (const chunk of innerCascade) {
259
295
  yield chunk;
@@ -269,13 +305,18 @@ export function createRotate(deps) {
269
305
  let switching = false;
270
306
  let inner;
271
307
  try {
308
+ // #464 & #461: Record attempt & trace dispatch
309
+ budget.recordAttempt();
310
+ const curCandidate = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
311
+ trace.addStep(curCandidate, 'DISPATCHED', 'ATTEMPT', `Attempt ${budget.getAttempts()}`);
312
+
272
313
  // mark the internal dispatch so the interceptor does not re-rotate
273
314
  inner = dispatchStorage.run(reqStore, () => {
274
315
  const llm = ctx.get('llm');
275
316
  if (!llm || typeof llm.stream !== 'function') {
276
317
  throw new Error('dsh-key-rotation: llm service unavailable');
277
318
  }
278
- return llm.stream({ ...options, [MARKER]: true });
319
+ return llm.stream({ ...options, [MARKER]: true, signal: budget.signal });
279
320
  });
280
321
  } catch (e) {
281
322
  if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
@@ -430,6 +471,7 @@ export function createRotate(deps) {
430
471
  }
431
472
  recordLatency(pool, reqStore);
432
473
  activePermit?.success();
474
+ if (isTraceOwner) trace.finish('SUCCESS', activeRef);
433
475
  yield chunk;
434
476
  return;
435
477
  }
@@ -468,6 +510,9 @@ export function createRotate(deps) {
468
510
  if (activeRef) recordSuccess(pool, activeRef, now());
469
511
  recordLatency(pool, reqStore);
470
512
  activePermit?.success();
513
+ if (isTraceOwner) trace.finish('SUCCESS', activeRef);
514
+ } else if (isTraceOwner) {
515
+ trace.finish('COMPLETED', reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed);
471
516
  }
472
517
  return; // clean end — served
473
518
  } finally {
@@ -481,6 +526,13 @@ export function createRotate(deps) {
481
526
  }
482
527
  }
483
528
 
529
+ if (isBudgetOwner) {
530
+ budget.dispose();
531
+ }
532
+ if (isTraceOwner) {
533
+ trace.finish('TERMINATED', reqStore.pickedRef ?? pool.state.lastUsed);
534
+ }
535
+
484
536
  // pool exhausted — all keys cooling or missing
485
537
  pool.state.lastExhaustionAt = now();
486
538
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
@@ -0,0 +1,80 @@
1
+ // lib/selection-trace.js — Request-scoped key selection explanation and attempt history (#461).
2
+ // Provides auditable reasoning for key skips, selection, failovers, and cascade with strict secret redaction.
3
+
4
+ import { keyTail } from './pool.js';
5
+
6
+ const TRACE_BUFFER_LIMIT = 100;
7
+ const traces = [];
8
+
9
+ /**
10
+ * Redacts any sensitive tokens from string / object representation.
11
+ */
12
+ export function redactSecret(val) {
13
+ if (typeof val !== 'string') return val;
14
+ // Key refs (env names, vault refs) or tails (sk-...1234) are safe.
15
+ // Full raw tokens (sk-..., key-..., etc.) are masked.
16
+ if (/^(?:sk-|key-|Bearer\s+|ghp_)[a-zA-Z0-9_\-\.]{12,}$/i.test(val)) {
17
+ return "..." + keyTail(val);
18
+ }
19
+ return val;
20
+ }
21
+
22
+ export function createSelectionTrace(requestId, options = {}) {
23
+ const reqId = requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
24
+ const startTime = Date.now();
25
+ const events = [];
26
+
27
+ return {
28
+ requestId: reqId,
29
+ provider: options.provider || '',
30
+ model: options.model || '',
31
+ startTime,
32
+
33
+ /**
34
+ * Record a candidate evaluation (selected, skipped, cooling, quota, etc.)
35
+ * @param {string} ref - Key identifier
36
+ * @param {string} decision - 'SELECTED' | 'SKIPPED' | 'DISPATCHED' | 'FAILED' | 'CASCADE'
37
+ * @param {string} reasonCode - Machine code e.g. 'COOLDOWN' | 'QUOTA' | 'PINNED_PRIORITY'
38
+ * @param {string} [details] - User-readable explanation
39
+ */
40
+ addStep(ref, decision, reasonCode, details = '') {
41
+ events.push({
42
+ at: Date.now(),
43
+ ref: redactSecret(ref),
44
+ decision,
45
+ reasonCode,
46
+ details: String(details).slice(0, 300),
47
+ });
48
+ },
49
+
50
+ /**
51
+ * Complete the trace and store into bounded history
52
+ */
53
+ finish(outcome, finalRef = null) {
54
+ const summary = {
55
+ requestId: reqId,
56
+ provider: options.provider || '',
57
+ model: options.model || '',
58
+ startTime,
59
+ durationMs: Date.now() - startTime,
60
+ outcome, // 'SUCCESS' | 'EXHAUSTED' | 'TIMEOUT' | 'CASCADE' | 'ERROR'
61
+ selectedRef: finalRef ? redactSecret(finalRef) : null,
62
+ steps: events,
63
+ };
64
+
65
+ if (traces.length >= TRACE_BUFFER_LIMIT) {
66
+ traces.shift();
67
+ }
68
+ traces.push(summary);
69
+ return summary;
70
+ }
71
+ };
72
+ }
73
+
74
+ export function getSelectionTraces(limit = 50) {
75
+ return traces.slice(-Math.max(1, Math.min(100, limit)));
76
+ }
77
+
78
+ export function clearSelectionTraces() {
79
+ traces.length = 0;
80
+ }
@@ -0,0 +1,156 @@
1
+ // lib/simulator.js — Offline rotation and cascade simulator (#462).
2
+ // Simulates key selection, failovers, cooldowns, and cross-provider cascade
3
+ // without network requests, credential resolution, token expenditure, or modifying production state.
4
+
5
+ import {
6
+ isKeyPaused,
7
+ isKeyRevoked,
8
+ isKeyBroken,
9
+ sortAttemptList,
10
+ } from './pool.js';
11
+ import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
12
+ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
13
+ import { createSelectionTrace } from './selection-trace.js';
14
+
15
+ /**
16
+ * Runs an offline simulation of rotation and cascade.
17
+ * @param {object} params
18
+ * @param {object} params.pool - Simulated pool definition
19
+ * @param {object} [params.runtime] - Simulated runtime with providerToPool, cascade, circuitBreaker
20
+ * @param {Array<object>} [params.events] - Simulated step events
21
+ * @param {number} [params.nowMs] - Simulated current timestamp
22
+ * @param {string} [params.model] - Optional model identifier
23
+ * @param {number} [params.maxAttempts] - Max allowed attempts (from #464)
24
+ * @returns {object} Simulation trace and result
25
+ */
26
+ export function simulateRouting(params = {}) {
27
+ const {
28
+ pool,
29
+ runtime = {},
30
+ events = [],
31
+ nowMs = Date.now(),
32
+ model = '',
33
+ maxAttempts = 0,
34
+ } = params;
35
+
36
+ if (!pool || !Array.isArray(pool.refs)) {
37
+ throw new Error('simulateRouting: valid pool with refs is required');
38
+ }
39
+
40
+ // Clone pool state into isolated mock state so real runtime is never touched
41
+ const mockFailedUntil = new Map(pool.state?.failedUntil || []);
42
+ const mockFailCounts = new Map(pool.state?.failCounts || []);
43
+ const mockRevokedRefs = new Set(pool.state?.revokedRefs || []);
44
+
45
+ const simPool = {
46
+ ...pool,
47
+ state: {
48
+ failedUntil: mockFailedUntil,
49
+ failCounts: mockFailCounts,
50
+ revokedRefs: mockRevokedRefs,
51
+ brokenUntil: new Map(),
52
+ },
53
+ };
54
+
55
+ const trace = createSelectionTrace(`sim-${nowMs}`, {
56
+ provider: pool.provider || pool.base || 'sim-provider',
57
+ model,
58
+ });
59
+
60
+ const quotaManaged = hasModelQuotaConfig(simPool);
61
+ let attemptList = (simPool.weightedRefs ?? simPool.refs).slice();
62
+
63
+ // Filter paused/revoked/broken
64
+ attemptList = attemptList.filter((r) => {
65
+ if (isKeyPaused(simPool, r)) {
66
+ trace.addStep(r, 'SKIPPED', 'PAUSED', 'Key is marked paused');
67
+ return false;
68
+ }
69
+ if (isKeyRevoked(simPool, r)) {
70
+ trace.addStep(r, 'SKIPPED', 'REVOKED', 'Key is revoked');
71
+ return false;
72
+ }
73
+ if (isKeyBroken(simPool, r, nowMs)) {
74
+ trace.addStep(r, 'SKIPPED', 'BROKEN', 'Key is in broken quarantine');
75
+ return false;
76
+ }
77
+ const fu = simPool.state.failedUntil.get(r) ?? 0;
78
+ if (fu > nowMs) {
79
+ trace.addStep(r, 'SKIPPED', 'COOLDOWN', `In cooldown for ${Math.round((fu - nowMs) / 1000)}s`);
80
+ return false;
81
+ }
82
+ return true;
83
+ });
84
+
85
+ // Prioritize pinnedRef
86
+ if (simPool.pinnedRef && attemptList.includes(simPool.pinnedRef)) {
87
+ const pIdx = attemptList.indexOf(simPool.pinnedRef);
88
+ if (pIdx > 0) {
89
+ attemptList.splice(pIdx, 1);
90
+ attemptList.unshift(simPool.pinnedRef);
91
+ trace.addStep(simPool.pinnedRef, 'PRIORITIZED', 'PINNED', 'Key is pinned');
92
+ }
93
+ }
94
+
95
+ // Model quota filter
96
+ if (quotaManaged) {
97
+ attemptList = attemptList.filter((ref) => {
98
+ const ok = isModelQuotaAvailable(simPool, ref, nowMs);
99
+ if (!ok) {
100
+ trace.addStep(ref, 'SKIPPED', 'QUOTA_EXHAUSTED', 'Local model token quota reached');
101
+ }
102
+ return ok;
103
+ });
104
+ }
105
+
106
+ let attemptCount = 0;
107
+ let finalOutcome = 'EXHAUSTED';
108
+ let finalRef = null;
109
+
110
+ for (let i = 0; i < attemptList.length; i++) {
111
+ const candidate = attemptList[i];
112
+ if (maxAttempts > 0 && attemptCount >= maxAttempts) {
113
+ trace.addStep(candidate, 'BLOCKED', 'BUDGET_ATTEMPTS_EXCEEDED', `Max attempts (${maxAttempts}) reached`);
114
+ finalOutcome = 'BUDGET_ATTEMPTS_EXCEEDED';
115
+ break;
116
+ }
117
+
118
+ attemptCount++;
119
+ trace.addStep(candidate, 'DISPATCHED', 'ATTEMPT', `Attempt ${attemptCount}`);
120
+
121
+ // Check if user specified a synthetic event/error for this candidate
122
+ const ev = events.find((e) => e.ref === candidate || e.attempt === attemptCount);
123
+ if (ev && ev.error) {
124
+ const errCode = ev.error.code || 'SIM_ERROR';
125
+ const errMsg = ev.error.message || 'Simulated failure';
126
+ trace.addStep(candidate, 'FAILED', errCode, errMsg);
127
+ // Apply simulated cooldown
128
+ simPool.state.failedUntil.set(candidate, nowMs + (ev.cooldownMs || 60000));
129
+ continue;
130
+ }
131
+
132
+ // Success simulated!
133
+ trace.addStep(candidate, 'SUCCESS', 'OK', 'Candidate served response');
134
+ finalOutcome = 'SUCCESS';
135
+ finalRef = candidate;
136
+ break;
137
+ }
138
+
139
+ // Cascade check if exhausted
140
+ if (finalOutcome === 'EXHAUSTED' && runtime.cascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
141
+ const fb = pickCascadeFallback(simPool.provider || simPool.base, runtime, runtime.providerToPool, model, runtime.modelPoolByProvider);
142
+ if (fb && fb.pool) {
143
+ trace.addStep(fb.provider, 'CASCADE', 'TRIGGERED', `Cascading to fallback provider ${fb.provider}`);
144
+ finalOutcome = 'CASCADE_TRIGGERED';
145
+ finalRef = fb.provider;
146
+ }
147
+ }
148
+
149
+ const result = trace.finish(finalOutcome, finalRef);
150
+ return {
151
+ outcome: finalOutcome,
152
+ selectedRef: finalRef,
153
+ attempts: attemptCount,
154
+ trace: result,
155
+ };
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.53",
3
+ "version": "0.8.54",
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": [