@goodandready/dsh-key-rotation 0.8.11 → 0.8.13
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 +196 -0
- package/lib/budget-monitor.js +123 -0
- package/lib/client.js +22 -13
- package/lib/index.js +199 -612
- package/lib/lifecycle.js +122 -0
- package/lib/logger.js +13 -0
- package/lib/notify-events.js +64 -0
- package/lib/ops-keys.js +189 -0
- package/lib/ops-paths.js +11 -0
- package/lib/ops-status.js +202 -0
- package/lib/ops-telemetry.js +113 -0
- package/lib/ops-test.js +106 -0
- package/lib/ops-webhook.js +115 -0
- package/lib/pool-builder.js +86 -0
- package/lib/rotate.js +10 -7
- package/lib/routes-ops.js +12 -631
- package/lib/sandbox-service.js +55 -0
- package/package.json +4 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// lib/ops-telemetry.js — usage + snapshot operational routes (#312 split from routes-ops.js).
|
|
2
|
+
import {
|
|
3
|
+
json,
|
|
4
|
+
readJson,
|
|
5
|
+
descriptorOf,
|
|
6
|
+
NS,
|
|
7
|
+
} from './http-bridge.js';
|
|
8
|
+
import { isTrustedBridgeRequest } from './pool.js';
|
|
9
|
+
import {
|
|
10
|
+
usageRows,
|
|
11
|
+
usageCsv,
|
|
12
|
+
} from './usage-report.js';
|
|
13
|
+
import {
|
|
14
|
+
USAGE_PATH,
|
|
15
|
+
SNAPSHOT_PATH,
|
|
16
|
+
} from './ops-paths.js';
|
|
17
|
+
import { findSecrets } from './keycheck.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} ctx cordis context
|
|
21
|
+
* @param {object} deps live dependencies from apply()
|
|
22
|
+
*/
|
|
23
|
+
export function registerTelemetryRoutes(ctx, deps) {
|
|
24
|
+
const {
|
|
25
|
+
buildRuntime,
|
|
26
|
+
} = deps;
|
|
27
|
+
|
|
28
|
+
ctx.effect(() => ctx.webServer.register({
|
|
29
|
+
kind: 'exact',
|
|
30
|
+
path: USAGE_PATH,
|
|
31
|
+
handler: (req, res) => {
|
|
32
|
+
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
33
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
|
|
34
|
+
const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
|
|
35
|
+
const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
|
|
36
|
+
const csv = url.searchParams.get('format') === 'csv';
|
|
37
|
+
const provider = url.searchParams.get('provider') ?? '';
|
|
38
|
+
const runtime = buildRuntime();
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const report = [];
|
|
42
|
+
for (const pool of runtime.poolByRef.values()) {
|
|
43
|
+
if (seen.has(pool.base)) continue;
|
|
44
|
+
seen.add(pool.base);
|
|
45
|
+
if (provider && pool.base !== provider) continue;
|
|
46
|
+
report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
|
|
47
|
+
}
|
|
48
|
+
if (csv) {
|
|
49
|
+
res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (const p of report) {
|
|
52
|
+
if (parts.length > 0) parts.push('');
|
|
53
|
+
parts.push('# ' + p.provider);
|
|
54
|
+
parts.push(usageCsv(p.rows));
|
|
55
|
+
}
|
|
56
|
+
res.end(parts.join('\n') + '\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
json(res, 200, { at: now, days, providers: report });
|
|
60
|
+
},
|
|
61
|
+
}), 'dsh-key-rotation: usage route');
|
|
62
|
+
|
|
63
|
+
// #218: full config snapshot - one JSON file to move between machines.
|
|
64
|
+
// Secret values never travel: only credential/env names. Token fields are
|
|
65
|
+
// exported as empty strings; on import they keep existing values when empty.
|
|
66
|
+
|
|
67
|
+
ctx.effect(() => ctx.webServer.register({
|
|
68
|
+
kind: 'exact',
|
|
69
|
+
path: SNAPSHOT_PATH,
|
|
70
|
+
handler: async (req, res) => {
|
|
71
|
+
if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
|
|
72
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
|
|
73
|
+
if (req.method === 'GET') {
|
|
74
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
75
|
+
const value = descriptor?.value ?? {};
|
|
76
|
+
const exportable = { ...value };
|
|
77
|
+
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
78
|
+
exportable.webhookActionToken = '';
|
|
79
|
+
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// POST = import: { snapshot } -> merge with current section, PUT semantics
|
|
83
|
+
let body;
|
|
84
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
85
|
+
const snap = body?.snapshot;
|
|
86
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
|
|
87
|
+
// #200 leak guard applies to imported content too
|
|
88
|
+
try {
|
|
89
|
+
const masked = structuredClone(snap);
|
|
90
|
+
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
91
|
+
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
92
|
+
const findings = findSecrets(JSON.stringify(masked));
|
|
93
|
+
if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
|
|
94
|
+
} catch { /* scanning must never block a valid import */ }
|
|
95
|
+
const settings = ctx.get('settings');
|
|
96
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
97
|
+
const desc = descriptorOf(ctx, NS);
|
|
98
|
+
if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
|
|
99
|
+
const cur = desc.value ?? {};
|
|
100
|
+
// empty token fields in the file keep the current values (never wipe a secret)
|
|
101
|
+
const merged = { ...cur, ...snap };
|
|
102
|
+
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
103
|
+
try {
|
|
104
|
+
await settings.replace(NS, merged, desc.revision);
|
|
105
|
+
const after = descriptorOf(ctx, NS);
|
|
106
|
+
json(res, 200, { ok: true, revision: after?.revision });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}), 'dsh-key-rotation: snapshot route');
|
|
112
|
+
|
|
113
|
+
}
|
package/lib/ops-test.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// lib/ops-test.js — sandbox test + sandbox cache routes (#312 split from routes-ops.js).
|
|
2
|
+
import {
|
|
3
|
+
json,
|
|
4
|
+
readJson,
|
|
5
|
+
} from './http-bridge.js';
|
|
6
|
+
import {
|
|
7
|
+
isTrustedBridgeRequest,
|
|
8
|
+
isValidRef,
|
|
9
|
+
keyTail,
|
|
10
|
+
envValue,
|
|
11
|
+
} from './pool.js';
|
|
12
|
+
import { bestEffort } from './best-effort.js';
|
|
13
|
+
import {
|
|
14
|
+
TEST_PATH,
|
|
15
|
+
SANDBOX_CACHE_PATH,
|
|
16
|
+
} from './ops-paths.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} ctx cordis context
|
|
20
|
+
* @param {object} deps live dependencies from apply()
|
|
21
|
+
*/
|
|
22
|
+
export function registerTestRoutes(ctx, deps) {
|
|
23
|
+
const {
|
|
24
|
+
lastTestCache,
|
|
25
|
+
ensureSandboxRunner,
|
|
26
|
+
poolState,
|
|
27
|
+
} = deps;
|
|
28
|
+
|
|
29
|
+
// ── test route: dry-run a single key without rotation ──
|
|
30
|
+
ctx.effect(() => ctx.webServer.register({
|
|
31
|
+
kind: 'exact',
|
|
32
|
+
path: TEST_PATH,
|
|
33
|
+
handler: async (req, res) => {
|
|
34
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
35
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
|
|
36
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
37
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
38
|
+
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
39
|
+
// Optional value for pre-save validation (issue #118)
|
|
40
|
+
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
41
|
+
const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
|
|
42
|
+
const base = ctx.get('credentials');
|
|
43
|
+
try {
|
|
44
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
45
|
+
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
46
|
+
const effectiveValue = testValue || hit?.value;
|
|
47
|
+
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
48
|
+
const tail = valid ? keyTail(effectiveValue) : '';
|
|
49
|
+
let source = null;
|
|
50
|
+
await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
|
|
51
|
+
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
52
|
+
if (!present && testValue) { source = 'pre-save'; }
|
|
53
|
+
else if (!present) {
|
|
54
|
+
const ev = envValue(ref);
|
|
55
|
+
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
56
|
+
}
|
|
57
|
+
// sandbox probe (models is free; chat is hook-only, see sandbox.js)
|
|
58
|
+
if (probe) {
|
|
59
|
+
const keyForProbe = effectiveValue;
|
|
60
|
+
const runner = ensureSandboxRunner(ctx);
|
|
61
|
+
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
62
|
+
const cached = { ...result, at: Date.now() };
|
|
63
|
+
lastTestCache.set(ref, cached);
|
|
64
|
+
if (cached.ok) {
|
|
65
|
+
for (const st of poolState.values()) {
|
|
66
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
67
|
+
st.failedUntil?.delete(ref);
|
|
68
|
+
st.failCounts?.delete(ref);
|
|
69
|
+
st.authFailCounts?.delete(ref);
|
|
70
|
+
st.brokenUntil?.delete(ref);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
json(res, 200, { ok: true, ref, tail, source });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}), 'dsh-key-rotation: test route');
|
|
83
|
+
|
|
84
|
+
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
85
|
+
// to a configured key pool; pass everything else (and internal dispatches)
|
|
86
|
+
// straight through.
|
|
87
|
+
// Read-only cache snapshot for clients (badge polling).
|
|
88
|
+
|
|
89
|
+
ctx.effect(() => ctx.webServer.register({
|
|
90
|
+
kind: 'exact',
|
|
91
|
+
path: SANDBOX_CACHE_PATH,
|
|
92
|
+
handler: (req, res) => {
|
|
93
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
|
|
94
|
+
const cacheSnap = typeof lastTestCache?.snapshot === 'function'
|
|
95
|
+
? lastTestCache.snapshot()
|
|
96
|
+
: (lastTestCache instanceof Map ? Object.fromEntries(lastTestCache) : (lastTestCache ?? {}));
|
|
97
|
+
json(res, 200, cacheSnap);
|
|
98
|
+
},
|
|
99
|
+
}), 'dsh-key-rotation: sandbox cache');
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
// #199 webhook-action: interactive webhook buttons call back here.
|
|
104
|
+
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
105
|
+
// cannot be same-origin, so a shared secret is the gate).
|
|
106
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
|
|
2
|
+
import {
|
|
3
|
+
json,
|
|
4
|
+
readJson,
|
|
5
|
+
} from './http-bridge.js';
|
|
6
|
+
import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} ctx cordis context
|
|
10
|
+
* @param {object} deps live dependencies from apply()
|
|
11
|
+
*/
|
|
12
|
+
export function registerWebhookActionRoute(ctx, deps) {
|
|
13
|
+
const {
|
|
14
|
+
buildRuntime,
|
|
15
|
+
poolState,
|
|
16
|
+
setRotationDisabled,
|
|
17
|
+
circuitBreaker,
|
|
18
|
+
} = deps;
|
|
19
|
+
|
|
20
|
+
ctx.effect(() => ctx.webServer.register({
|
|
21
|
+
kind: 'exact',
|
|
22
|
+
path: WEBHOOK_ACTION_PATH,
|
|
23
|
+
handler: async (req, res) => {
|
|
24
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
25
|
+
const runtime = buildRuntime();
|
|
26
|
+
const expected = runtime.webhookActionToken;
|
|
27
|
+
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
28
|
+
const auth = String(req.headers.authorization ?? '');
|
|
29
|
+
if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
|
|
30
|
+
let body;
|
|
31
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
32
|
+
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
33
|
+
let action = typeof body?.action === 'string' ? body.action : '';
|
|
34
|
+
if (!action && typeof body?.data === 'string') {
|
|
35
|
+
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
36
|
+
}
|
|
37
|
+
if (!action && typeof body?.callback_data === 'string') {
|
|
38
|
+
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
39
|
+
}
|
|
40
|
+
// #222: Telegram update envelope {update_id, callback_query:{data}}
|
|
41
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
42
|
+
try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
|
|
43
|
+
}
|
|
44
|
+
// #222: Telegram setWebhook registration helper
|
|
45
|
+
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
46
|
+
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
47
|
+
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
48
|
+
// derive the public URL from request headers; explicit URL wins
|
|
49
|
+
const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
|
|
50
|
+
try {
|
|
51
|
+
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
52
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
|
|
54
|
+
});
|
|
55
|
+
const hookData = await hookRes.json().catch(() => ({}));
|
|
56
|
+
json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
|
|
63
|
+
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
64
|
+
try {
|
|
65
|
+
if (action === 'disable-rotation') {
|
|
66
|
+
setRotationDisabled(true);
|
|
67
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
68
|
+
json(res, 200, { ok: true, action });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (action === 'enable-rotation') {
|
|
72
|
+
setRotationDisabled(false);
|
|
73
|
+
json(res, 200, { ok: true, action });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
77
|
+
const st = poolState.get(provider);
|
|
78
|
+
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
79
|
+
if (action.startsWith('pause-')) {
|
|
80
|
+
const until = Date.now() + 3600000; // 1h pause
|
|
81
|
+
for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
82
|
+
// also pause every key currently healthy
|
|
83
|
+
for (const p of buildRuntime().poolByRef.values()) {
|
|
84
|
+
if (p.base !== provider) continue;
|
|
85
|
+
for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
86
|
+
}
|
|
87
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
88
|
+
json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const cleared = st.failedUntil.size;
|
|
92
|
+
st.failedUntil.clear();
|
|
93
|
+
st.failCounts?.clear();
|
|
94
|
+
st.authFailCounts?.clear();
|
|
95
|
+
st.brokenUntil?.clear();
|
|
96
|
+
st.switches = 0;
|
|
97
|
+
st.lastReason = undefined;
|
|
98
|
+
st.lastSwitchAt = undefined;
|
|
99
|
+
let circuitReset = false;
|
|
100
|
+
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
101
|
+
if (br) {
|
|
102
|
+
if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
|
|
103
|
+
else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
|
|
104
|
+
}
|
|
105
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
|
|
106
|
+
json(res, 200, { ok: true, action, provider, cleared, circuitReset });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}), 'dsh-key-rotation: webhook-action');
|
|
115
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// lib/pool-builder.js — provider & per-model pool assembly and cleanup
|
|
2
|
+
import { bucketSweep } from './bucket.js';
|
|
3
|
+
|
|
4
|
+
export function parseExpiry(v) {
|
|
5
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
6
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
7
|
+
const t = Date.parse(v);
|
|
8
|
+
if (!Number.isNaN(t)) return t;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildPoolItem({
|
|
14
|
+
base,
|
|
15
|
+
keys,
|
|
16
|
+
weights,
|
|
17
|
+
poolCooldown,
|
|
18
|
+
poolMax,
|
|
19
|
+
expiresAt,
|
|
20
|
+
poolStrategy,
|
|
21
|
+
poolGuard,
|
|
22
|
+
rpmLimit,
|
|
23
|
+
makeState,
|
|
24
|
+
}) {
|
|
25
|
+
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
26
|
+
if (refs.length === 0) return null;
|
|
27
|
+
const w = Array.isArray(weights) ? weights : [];
|
|
28
|
+
const weightedRefs = [];
|
|
29
|
+
for (let i = 0; i < refs.length; i++) {
|
|
30
|
+
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
31
|
+
for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
|
|
32
|
+
}
|
|
33
|
+
const parsedExpiry = {};
|
|
34
|
+
if (Array.isArray(expiresAt)) {
|
|
35
|
+
for (let i = 0; i < refs.length; i++) {
|
|
36
|
+
const exp = parseExpiry(expiresAt[i]);
|
|
37
|
+
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
base,
|
|
42
|
+
refs,
|
|
43
|
+
weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
44
|
+
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
45
|
+
state: makeState(base),
|
|
46
|
+
cooldownMs: poolCooldown,
|
|
47
|
+
maxCooldownMs: poolMax,
|
|
48
|
+
expiresAt: parsedExpiry,
|
|
49
|
+
rpmLimit,
|
|
50
|
+
routingStrategy: poolStrategy,
|
|
51
|
+
proactiveRateLimitGuard: poolGuard,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function cleanupRemovedProviders({
|
|
56
|
+
cfg,
|
|
57
|
+
poolState,
|
|
58
|
+
poolByRef,
|
|
59
|
+
providerToPool,
|
|
60
|
+
expectedClones,
|
|
61
|
+
moduleBreaker,
|
|
62
|
+
lowHealthNotifiedAt,
|
|
63
|
+
budgetNotifiedAt,
|
|
64
|
+
}) {
|
|
65
|
+
// auto-cleanup: remove poolState for providers that are now empty or removed
|
|
66
|
+
for (const key of [...poolState.keys()]) {
|
|
67
|
+
if (![...poolByRef.values()].some((p) => p.base === key)) {
|
|
68
|
+
poolState.delete(key);
|
|
69
|
+
lowHealthNotifiedAt?.delete?.(key);
|
|
70
|
+
budgetNotifiedAt?.delete?.(key + ':budget');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// #192: drop RPM windows for refs that no longer belong to any pool
|
|
74
|
+
for (const st of poolState.values()) {
|
|
75
|
+
if (st.rpmWindows) bucketSweep(st.rpmWindows, new Set(poolByRef.keys()));
|
|
76
|
+
}
|
|
77
|
+
// drop breaker entries for removed providers
|
|
78
|
+
if (moduleBreaker) {
|
|
79
|
+
for (const key of Object.keys(moduleBreaker.snapshot())) {
|
|
80
|
+
if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
|
|
81
|
+
const still = (cfg.providers ?? []).some((p) => p.provider === key);
|
|
82
|
+
if (!still) moduleBreaker.reset(key);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -33,9 +33,12 @@ export function createRotate(deps) {
|
|
|
33
33
|
setRotateStartMs,
|
|
34
34
|
quotaStore,
|
|
35
35
|
circuitBreaker,
|
|
36
|
+
logger,
|
|
36
37
|
now = nowMono,
|
|
37
38
|
} = deps;
|
|
38
39
|
|
|
40
|
+
const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
|
|
41
|
+
|
|
39
42
|
function rotate(options, pool) {
|
|
40
43
|
return (async function* () {
|
|
41
44
|
const runtime0 = buildRuntime();
|
|
@@ -93,7 +96,7 @@ export function createRotate(deps) {
|
|
|
93
96
|
|
|
94
97
|
// #260: fail fast when provider circuit is open
|
|
95
98
|
if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
|
|
96
|
-
|
|
99
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
97
100
|
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
@@ -116,7 +119,7 @@ export function createRotate(deps) {
|
|
|
116
119
|
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
117
120
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
118
121
|
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
119
|
-
|
|
122
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
120
123
|
continue;
|
|
121
124
|
}
|
|
122
125
|
|
|
@@ -146,7 +149,7 @@ export function createRotate(deps) {
|
|
|
146
149
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
147
150
|
pool.state.lastSwitchAt = now();
|
|
148
151
|
lastFailure = chunk;
|
|
149
|
-
|
|
152
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
150
153
|
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
151
154
|
if (switchNotify && activeRef) {
|
|
152
155
|
notifySwitch(runtime0, pool, {
|
|
@@ -206,7 +209,7 @@ export function createRotate(deps) {
|
|
|
206
209
|
const effCool = Math.min(cool, maxCool ?? cool);
|
|
207
210
|
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
208
211
|
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
209
|
-
|
|
212
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
210
213
|
}
|
|
211
214
|
}
|
|
212
215
|
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
@@ -229,7 +232,7 @@ export function createRotate(deps) {
|
|
|
229
232
|
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
230
233
|
pool.state.lastSwitchAt = now();
|
|
231
234
|
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
232
|
-
|
|
235
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
233
236
|
if (switchNotify && activeRef) {
|
|
234
237
|
notifySwitch(runtime0, pool, {
|
|
235
238
|
provider: options.provider,
|
|
@@ -256,7 +259,7 @@ export function createRotate(deps) {
|
|
|
256
259
|
// pool exhausted — all keys cooling or missing
|
|
257
260
|
pool.state.lastExhaustionAt = now();
|
|
258
261
|
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
259
|
-
|
|
262
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
260
263
|
const runtime = buildRuntime();
|
|
261
264
|
// notify via extracted helper (see notifyExhaustion above)
|
|
262
265
|
notifyExhaustion(runtime, pool, { provider: options.provider });
|
|
@@ -266,7 +269,7 @@ export function createRotate(deps) {
|
|
|
266
269
|
const pools = runtime.providerToPool;
|
|
267
270
|
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
268
271
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
269
|
-
|
|
272
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
270
273
|
pool.state.lastReason = 'CASCADE';
|
|
271
274
|
pool.state.lastSwitchAt = now();
|
|
272
275
|
// Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
|