@goodandready/dsh-key-rotation 0.7.29 → 0.7.31
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/LICENSE +21 -21
- package/README.md +245 -165
- package/README.ru.md +245 -0
- package/README.zh.md +206 -0
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +129 -52
- package/lib/canary.js +63 -56
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +1087 -1033
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +83 -1
- package/lib/pool.js +264 -237
- package/lib/quota.js +39 -39
- package/lib/region.js +50 -50
- package/lib/sandbox.js +117 -117
- package/lib/shadow.js +81 -81
- package/lib/usage-report.js +79 -49
- package/lib/webhook.js +193 -133
- package/package.json +58 -58
package/lib/heal.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
// heal.js — self-healing idle cooldowns.
|
|
2
|
-
// ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
|
|
3
|
-
|
|
4
|
-
// Returns array of { ref, poolBase } entries that were healed in this tick.
|
|
5
|
-
// Mutates `pools` (removes from failedUntil, pushes heal event into events).
|
|
6
|
-
// `now` parameter is injectable for tests.
|
|
7
|
-
export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
8
|
-
if (!Array.isArray(pools) || pools.length === 0) return [];
|
|
9
|
-
if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
|
|
10
|
-
const healed = [];
|
|
11
|
-
for (const pool of pools) {
|
|
12
|
-
if (!pool || !pool.state || !pool.base) continue;
|
|
13
|
-
const fu = pool.state.failedUntil;
|
|
14
|
-
const lu = pool.state.lastUsed;
|
|
15
|
-
if (!fu || fu.size === 0) continue;
|
|
16
|
-
const expiredRefs = [];
|
|
17
|
-
for (const [ref, until] of fu.entries()) {
|
|
18
|
-
if (!Number.isFinite(until)) continue;
|
|
19
|
-
if (until > now) continue; // cooldown still active
|
|
20
|
-
const last = lu ? lu.get(ref) : undefined;
|
|
21
|
-
if (!Number.isFinite(last)) continue; // never used → no signal, skip
|
|
22
|
-
if (now - last < idleMs) continue; // used recently → don't heal
|
|
23
|
-
expiredRefs.push(ref);
|
|
24
|
-
}
|
|
25
|
-
for (const ref of expiredRefs) {
|
|
26
|
-
fu.delete(ref);
|
|
27
|
-
if (Array.isArray(pool.state.events)) {
|
|
28
|
-
pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
|
|
29
|
-
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
30
|
-
}
|
|
31
|
-
healed.push({ ref, poolBase: pool.base });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return healed;
|
|
35
|
-
}
|
|
1
|
+
// heal.js — self-healing idle cooldowns.
|
|
2
|
+
// ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
|
|
3
|
+
|
|
4
|
+
// Returns array of { ref, poolBase } entries that were healed in this tick.
|
|
5
|
+
// Mutates `pools` (removes from failedUntil, pushes heal event into events).
|
|
6
|
+
// `now` parameter is injectable for tests.
|
|
7
|
+
export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
8
|
+
if (!Array.isArray(pools) || pools.length === 0) return [];
|
|
9
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
|
|
10
|
+
const healed = [];
|
|
11
|
+
for (const pool of pools) {
|
|
12
|
+
if (!pool || !pool.state || !pool.base) continue;
|
|
13
|
+
const fu = pool.state.failedUntil;
|
|
14
|
+
const lu = pool.state.lastUsed;
|
|
15
|
+
if (!fu || fu.size === 0) continue;
|
|
16
|
+
const expiredRefs = [];
|
|
17
|
+
for (const [ref, until] of fu.entries()) {
|
|
18
|
+
if (!Number.isFinite(until)) continue;
|
|
19
|
+
if (until > now) continue; // cooldown still active
|
|
20
|
+
const last = lu ? lu.get(ref) : undefined;
|
|
21
|
+
if (!Number.isFinite(last)) continue; // never used → no signal, skip
|
|
22
|
+
if (now - last < idleMs) continue; // used recently → don't heal
|
|
23
|
+
expiredRefs.push(ref);
|
|
24
|
+
}
|
|
25
|
+
for (const ref of expiredRefs) {
|
|
26
|
+
fu.delete(ref);
|
|
27
|
+
if (Array.isArray(pool.state.events)) {
|
|
28
|
+
pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
|
|
29
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
30
|
+
}
|
|
31
|
+
healed.push({ ref, poolBase: pool.base });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return healed;
|
|
35
|
+
}
|
package/lib/histogram.js
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
// histogram.js — per-ref latency ring buffer + percentile.
|
|
2
|
-
// ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
|
|
3
|
-
|
|
4
|
-
export const LATENCY_DEFAULT_WINDOW = 200;
|
|
5
|
-
|
|
6
|
-
export class LatencyHistogram {
|
|
7
|
-
constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
|
|
8
|
-
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
|
|
9
|
-
this._window = w;
|
|
10
|
-
this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
|
|
11
|
-
this._lastAt = new Map(); // ref -> epochMs of last sample
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
record(ref, ms) {
|
|
15
|
-
if (!ref || !Number.isFinite(ms) || ms < 0) return;
|
|
16
|
-
let entry = this._buffers.get(ref);
|
|
17
|
-
if (!entry) {
|
|
18
|
-
entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
|
|
19
|
-
this._buffers.set(ref, entry);
|
|
20
|
-
}
|
|
21
|
-
entry.buf[entry.head] = ms;
|
|
22
|
-
entry.head = (entry.head + 1) % this._window;
|
|
23
|
-
if (entry.count < this._window) entry.count += 1;
|
|
24
|
-
this._lastAt.set(ref, Date.now());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
|
|
28
|
-
snapshot(ref) {
|
|
29
|
-
const entry = this._buffers.get(ref);
|
|
30
|
-
const lastAt = this._lastAt.get(ref);
|
|
31
|
-
if (!entry || entry.count === 0) {
|
|
32
|
-
return { count: 0, lastAt: lastAt || null };
|
|
33
|
-
}
|
|
34
|
-
const arr = entry.buf.subarray(0, entry.count);
|
|
35
|
-
const sorted = Array.from(arr).sort((a, b) => a - b);
|
|
36
|
-
const n = sorted.length;
|
|
37
|
-
return {
|
|
38
|
-
count: n,
|
|
39
|
-
lastAt: lastAt || null,
|
|
40
|
-
p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
|
|
41
|
-
p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
|
|
42
|
-
p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Returns { [ref]: snapshot }
|
|
47
|
-
snapshotAll() {
|
|
48
|
-
const out = {};
|
|
49
|
-
for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
clear(ref) {
|
|
54
|
-
if (ref) {
|
|
55
|
-
this._buffers.delete(ref);
|
|
56
|
-
this._lastAt.delete(ref);
|
|
57
|
-
} else {
|
|
58
|
-
this._buffers.clear();
|
|
59
|
-
this._lastAt.clear();
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
get size() {
|
|
64
|
-
return this._buffers.size;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
1
|
+
// histogram.js — per-ref latency ring buffer + percentile.
|
|
2
|
+
// ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
|
|
3
|
+
|
|
4
|
+
export const LATENCY_DEFAULT_WINDOW = 200;
|
|
5
|
+
|
|
6
|
+
export class LatencyHistogram {
|
|
7
|
+
constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
|
|
8
|
+
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
|
|
9
|
+
this._window = w;
|
|
10
|
+
this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
|
|
11
|
+
this._lastAt = new Map(); // ref -> epochMs of last sample
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
record(ref, ms) {
|
|
15
|
+
if (!ref || !Number.isFinite(ms) || ms < 0) return;
|
|
16
|
+
let entry = this._buffers.get(ref);
|
|
17
|
+
if (!entry) {
|
|
18
|
+
entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
|
|
19
|
+
this._buffers.set(ref, entry);
|
|
20
|
+
}
|
|
21
|
+
entry.buf[entry.head] = ms;
|
|
22
|
+
entry.head = (entry.head + 1) % this._window;
|
|
23
|
+
if (entry.count < this._window) entry.count += 1;
|
|
24
|
+
this._lastAt.set(ref, Date.now());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
|
|
28
|
+
snapshot(ref) {
|
|
29
|
+
const entry = this._buffers.get(ref);
|
|
30
|
+
const lastAt = this._lastAt.get(ref);
|
|
31
|
+
if (!entry || entry.count === 0) {
|
|
32
|
+
return { count: 0, lastAt: lastAt || null };
|
|
33
|
+
}
|
|
34
|
+
const arr = entry.buf.subarray(0, entry.count);
|
|
35
|
+
const sorted = Array.from(arr).sort((a, b) => a - b);
|
|
36
|
+
const n = sorted.length;
|
|
37
|
+
return {
|
|
38
|
+
count: n,
|
|
39
|
+
lastAt: lastAt || null,
|
|
40
|
+
p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
|
|
41
|
+
p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
|
|
42
|
+
p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns { [ref]: snapshot }
|
|
47
|
+
snapshotAll() {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clear(ref) {
|
|
54
|
+
if (ref) {
|
|
55
|
+
this._buffers.delete(ref);
|
|
56
|
+
this._lastAt.delete(ref);
|
|
57
|
+
} else {
|
|
58
|
+
this._buffers.clear();
|
|
59
|
+
this._lastAt.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get size() {
|
|
64
|
+
return this._buffers.size;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/incident.js
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
// lib/incident.js — auto-create Gitea issue when pool exhausted > threshold.
|
|
2
|
-
// ponytail: minimal — caller provides a token + base URL. No retries on rate-limit.
|
|
3
|
-
|
|
4
|
-
export const INCIDENT_DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
|
|
5
|
-
export const INCIDENT_DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 min between incidents per provider
|
|
6
|
-
export const INCIDENT_TIMEOUT_MS = 5000;
|
|
7
|
-
|
|
8
|
-
export class IncidentReporter {
|
|
9
|
-
constructor({ token, baseUrl, repo, thresholdMs = INCIDENT_DEFAULT_THRESHOLD_MS, cooldownMs = INCIDENT_DEFAULT_COOLDOWN_MS, fetchImpl } = {}) {
|
|
10
|
-
if (!token) throw new Error('incident: token required');
|
|
11
|
-
if (!baseUrl) throw new Error('incident: baseUrl required');
|
|
12
|
-
if (!repo || !repo.includes('/')) throw new Error('incident: repo (owner/name) required');
|
|
13
|
-
this._token = token;
|
|
14
|
-
this._baseUrl = baseUrl.replace(/\/+$/, '');
|
|
15
|
-
this._repo = repo;
|
|
16
|
-
this._thresholdMs = thresholdMs;
|
|
17
|
-
this._cooldownMs = cooldownMs;
|
|
18
|
-
this._lastIncidentAt = new Map(); // provider -> epochMs
|
|
19
|
-
this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : () => { throw new Error('incident: no fetch available'); });
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Should we report now? Pure; does not perform I/O.
|
|
23
|
-
shouldReport(provider, exhaustedSince, now = Date.now()) {
|
|
24
|
-
if (!provider) return false;
|
|
25
|
-
if (!Number.isFinite(exhaustedSince)) return false;
|
|
26
|
-
if (now - exhaustedSince < this._thresholdMs) return false;
|
|
27
|
-
const last = this._lastIncidentAt.get(provider);
|
|
28
|
-
if (Number.isFinite(last) && now - last < this._cooldownMs) return false;
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
markReported(provider, at = Date.now()) {
|
|
33
|
-
this._lastIncidentAt.set(provider, at);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
resetCooldown(provider) {
|
|
37
|
-
if (provider) this._lastIncidentAt.delete(provider);
|
|
38
|
-
else this._lastIncidentAt.clear();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Open a Gitea issue. ponytail: minimal payload, ignore failures.
|
|
42
|
-
async open(provider, exhaustedSince, now = Date.now()) {
|
|
43
|
-
if (!this.shouldReport(provider, exhaustedSince, now)) return { reported: false };
|
|
44
|
-
const url = `${this._baseUrl}/api/v1/repos/${this._repo}/issues`;
|
|
45
|
-
const body = {
|
|
46
|
-
title: `prod-incident: pool ${provider} exhausted since ${new Date(exhaustedSince).toISOString()}`,
|
|
47
|
-
body: [
|
|
48
|
-
'Auto-generated by `dsh-key-rotation`.',
|
|
49
|
-
'',
|
|
50
|
-
`- provider: \`${provider}\``,
|
|
51
|
-
`- exhaustedSince: \`${new Date(exhaustedSince).toISOString()}\``,
|
|
52
|
-
'',
|
|
53
|
-
'All keys in the pool are in cooldown or missing. Check OpenCode provider status and rotate keys.',
|
|
54
|
-
].join('\n'),
|
|
55
|
-
labels: ['prod-incident'],
|
|
56
|
-
};
|
|
57
|
-
const ctrl = new AbortController();
|
|
58
|
-
const timer = setTimeout(() => ctrl.abort(), INCIDENT_TIMEOUT_MS);
|
|
59
|
-
try {
|
|
60
|
-
const res = await this._fetch(url, {
|
|
61
|
-
method: 'POST',
|
|
62
|
-
headers: { authorization: `token ${this._token}`, 'content-type': 'application/json' },
|
|
63
|
-
body: JSON.stringify(body),
|
|
64
|
-
signal: ctrl.signal,
|
|
65
|
-
});
|
|
66
|
-
if (!res.ok) return { reported: false, status: res.status };
|
|
67
|
-
const data = await res.json();
|
|
68
|
-
this.markReported(provider, now);
|
|
69
|
-
return { reported: true, number: data.number, url: data.html_url };
|
|
70
|
-
} catch (_) {
|
|
71
|
-
return { reported: false };
|
|
72
|
-
} finally {
|
|
73
|
-
clearTimeout(timer);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
1
|
+
// lib/incident.js — auto-create Gitea issue when pool exhausted > threshold.
|
|
2
|
+
// ponytail: minimal — caller provides a token + base URL. No retries on rate-limit.
|
|
3
|
+
|
|
4
|
+
export const INCIDENT_DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
|
|
5
|
+
export const INCIDENT_DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 min between incidents per provider
|
|
6
|
+
export const INCIDENT_TIMEOUT_MS = 5000;
|
|
7
|
+
|
|
8
|
+
export class IncidentReporter {
|
|
9
|
+
constructor({ token, baseUrl, repo, thresholdMs = INCIDENT_DEFAULT_THRESHOLD_MS, cooldownMs = INCIDENT_DEFAULT_COOLDOWN_MS, fetchImpl } = {}) {
|
|
10
|
+
if (!token) throw new Error('incident: token required');
|
|
11
|
+
if (!baseUrl) throw new Error('incident: baseUrl required');
|
|
12
|
+
if (!repo || !repo.includes('/')) throw new Error('incident: repo (owner/name) required');
|
|
13
|
+
this._token = token;
|
|
14
|
+
this._baseUrl = baseUrl.replace(/\/+$/, '');
|
|
15
|
+
this._repo = repo;
|
|
16
|
+
this._thresholdMs = thresholdMs;
|
|
17
|
+
this._cooldownMs = cooldownMs;
|
|
18
|
+
this._lastIncidentAt = new Map(); // provider -> epochMs
|
|
19
|
+
this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : () => { throw new Error('incident: no fetch available'); });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Should we report now? Pure; does not perform I/O.
|
|
23
|
+
shouldReport(provider, exhaustedSince, now = Date.now()) {
|
|
24
|
+
if (!provider) return false;
|
|
25
|
+
if (!Number.isFinite(exhaustedSince)) return false;
|
|
26
|
+
if (now - exhaustedSince < this._thresholdMs) return false;
|
|
27
|
+
const last = this._lastIncidentAt.get(provider);
|
|
28
|
+
if (Number.isFinite(last) && now - last < this._cooldownMs) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
markReported(provider, at = Date.now()) {
|
|
33
|
+
this._lastIncidentAt.set(provider, at);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resetCooldown(provider) {
|
|
37
|
+
if (provider) this._lastIncidentAt.delete(provider);
|
|
38
|
+
else this._lastIncidentAt.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Open a Gitea issue. ponytail: minimal payload, ignore failures.
|
|
42
|
+
async open(provider, exhaustedSince, now = Date.now()) {
|
|
43
|
+
if (!this.shouldReport(provider, exhaustedSince, now)) return { reported: false };
|
|
44
|
+
const url = `${this._baseUrl}/api/v1/repos/${this._repo}/issues`;
|
|
45
|
+
const body = {
|
|
46
|
+
title: `prod-incident: pool ${provider} exhausted since ${new Date(exhaustedSince).toISOString()}`,
|
|
47
|
+
body: [
|
|
48
|
+
'Auto-generated by `dsh-key-rotation`.',
|
|
49
|
+
'',
|
|
50
|
+
`- provider: \`${provider}\``,
|
|
51
|
+
`- exhaustedSince: \`${new Date(exhaustedSince).toISOString()}\``,
|
|
52
|
+
'',
|
|
53
|
+
'All keys in the pool are in cooldown or missing. Check OpenCode provider status and rotate keys.',
|
|
54
|
+
].join('\n'),
|
|
55
|
+
labels: ['prod-incident'],
|
|
56
|
+
};
|
|
57
|
+
const ctrl = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => ctrl.abort(), INCIDENT_TIMEOUT_MS);
|
|
59
|
+
try {
|
|
60
|
+
const res = await this._fetch(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { authorization: `token ${this._token}`, 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
signal: ctrl.signal,
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return { reported: false, status: res.status };
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
this.markReported(provider, now);
|
|
69
|
+
return { reported: true, number: data.number, url: data.html_url };
|
|
70
|
+
} catch (_) {
|
|
71
|
+
return { reported: false };
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -86,6 +86,8 @@ let rotationDisabled = false;
|
|
|
86
86
|
const expiryNotifiedAt = new Map();
|
|
87
87
|
const budgetNotifiedAt = new Map();
|
|
88
88
|
const switchNotifiedAt = new Map();
|
|
89
|
+
const lowHealthNotifiedAt = new Map();
|
|
90
|
+
const sloNotifiedAt = new Map();
|
|
89
91
|
const DAY_MS = 86400000;
|
|
90
92
|
|
|
91
93
|
// #216: one webhook per switch, deduped to at most one message per provider
|
|
@@ -215,6 +217,8 @@ export const Config = Schema.object({
|
|
|
215
217
|
expiryWarnDays: Schema.number().default(7),
|
|
216
218
|
switchNotify: Schema.boolean().default(false),
|
|
217
219
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
220
|
+
warnBelowHealthy: Schema.number().default(0),
|
|
221
|
+
latencySloMs: Schema.number().default(0),
|
|
218
222
|
providers: Schema.array(Schema.object({
|
|
219
223
|
provider: Schema.string().required(),
|
|
220
224
|
keys: Schema.array(Schema.string()).default([]),
|
|
@@ -633,6 +637,55 @@ export function apply(ctx, config = {}) {
|
|
|
633
637
|
if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
|
|
634
638
|
}
|
|
635
639
|
}
|
|
640
|
+
// #221: pool running low - webhook while healthy < warnBelowHealthy
|
|
641
|
+
const warnBelow = runtime.warnBelowHealthy ?? 0;
|
|
642
|
+
if (warnBelow > 0) {
|
|
643
|
+
let healthy = 0;
|
|
644
|
+
for (const ref of pool.refs) {
|
|
645
|
+
const fu = pool.state.failedUntil.get(ref);
|
|
646
|
+
if (fu !== undefined && fu > now) continue;
|
|
647
|
+
const exp = pool.expiresAt?.[ref];
|
|
648
|
+
if (exp !== undefined && now >= exp) continue;
|
|
649
|
+
healthy++;
|
|
650
|
+
}
|
|
651
|
+
if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
|
|
652
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
|
|
653
|
+
if (runtime.notifyWebhook) {
|
|
654
|
+
const token = runtime.webhookActionToken ?? '';
|
|
655
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
656
|
+
title: `Pool running low: ${pool.base}`,
|
|
657
|
+
text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
|
|
658
|
+
provider: pool.base,
|
|
659
|
+
kind: 'low-health',
|
|
660
|
+
healthy,
|
|
661
|
+
total: pool.refs.length,
|
|
662
|
+
actionToken: token || undefined,
|
|
663
|
+
actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// #225: latency SLO - webhook when a key's p95 exceeds the threshold
|
|
669
|
+
const slo = runtime.latencySloMs ?? 0;
|
|
670
|
+
if (slo > 0) {
|
|
671
|
+
for (const ref of pool.refs) {
|
|
672
|
+
const snap = latencyHistogram.snapshot(ref);
|
|
673
|
+
if (!snap.p95 || snap.p95 <= slo) continue;
|
|
674
|
+
if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
|
|
675
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
|
|
676
|
+
if (runtime.notifyWebhook) {
|
|
677
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
678
|
+
title: `Latency SLO exceeded: ${pool.base}`,
|
|
679
|
+
text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
|
|
680
|
+
provider: pool.base,
|
|
681
|
+
kind: 'latency-slo',
|
|
682
|
+
ref,
|
|
683
|
+
p95: Math.round(snap.p95),
|
|
684
|
+
slo,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
636
689
|
}
|
|
637
690
|
} catch (_) { /* maintenance must never crash the sweep */ }
|
|
638
691
|
}, 30000);
|
|
@@ -776,7 +829,7 @@ export function apply(ctx, config = {}) {
|
|
|
776
829
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
777
830
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
778
831
|
}
|
|
779
|
-
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
832
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
780
833
|
}
|
|
781
834
|
|
|
782
835
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -1104,6 +1157,7 @@ export function apply(ctx, config = {}) {
|
|
|
1104
1157
|
const { poolByRef, providerTags, providerBudgets } = buildRuntime();
|
|
1105
1158
|
const base = ctx.get('credentials');
|
|
1106
1159
|
const now = Date.now();
|
|
1160
|
+
const latencySloMs = buildRuntime().latencySloMs;
|
|
1107
1161
|
const seen = new Set();
|
|
1108
1162
|
const providers = [];
|
|
1109
1163
|
for (const pool of poolByRef.values()) {
|
|
@@ -1171,6 +1225,12 @@ export function apply(ctx, config = {}) {
|
|
|
1171
1225
|
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
1172
1226
|
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
1173
1227
|
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
1228
|
+
// #225: aggregate p95 across the pool's keys
|
|
1229
|
+
p95: (() => {
|
|
1230
|
+
const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
|
|
1231
|
+
return vals.length ? Math.round(Math.max(...vals)) : null;
|
|
1232
|
+
})(),
|
|
1233
|
+
latencySloMs,
|
|
1174
1234
|
events: (pool.state.events ?? []).slice(-50),
|
|
1175
1235
|
healthScore: computeHealthScore(pool.state),
|
|
1176
1236
|
// #208: today/week spend + configured budget for the card
|
|
@@ -1592,6 +1652,28 @@ export function apply(ctx, config = {}) {
|
|
|
1592
1652
|
if (!action && typeof body?.callback_data === 'string') {
|
|
1593
1653
|
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
1594
1654
|
}
|
|
1655
|
+
// #222: Telegram update envelope {update_id, callback_query:{data}}
|
|
1656
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
1657
|
+
try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
|
|
1658
|
+
}
|
|
1659
|
+
// #222: Telegram setWebhook registration helper
|
|
1660
|
+
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
1661
|
+
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
1662
|
+
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
1663
|
+
// derive the public URL from request headers; explicit URL wins
|
|
1664
|
+
const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
|
|
1665
|
+
try {
|
|
1666
|
+
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
1667
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1668
|
+
body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
|
|
1669
|
+
});
|
|
1670
|
+
const hookData = await hookRes.json().catch(() => ({}));
|
|
1671
|
+
json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
1674
|
+
}
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1595
1677
|
if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
|
|
1596
1678
|
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
1597
1679
|
try {
|