@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 +18 -0
- package/lib/index.js +2 -0
- package/lib/key-drain.js +54 -0
- package/lib/ops-keys.js +51 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-telemetry.js +71 -0
- package/lib/request-budget.js +112 -0
- package/lib/rotate.js +54 -2
- package/lib/selection-trace.js +80 -0
- package/lib/simulator.js +156 -0
- package/package.json +1 -1
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),
|
package/lib/key-drain.js
ADDED
|
@@ -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';
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/simulator.js
ADDED
|
@@ -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.
|
|
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": [
|