@goodandready/dsh-key-rotation 0.7.21 → 0.7.23
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/lib/agent-budget.js +68 -0
- package/lib/incident.js +76 -0
- package/lib/index.js +140 -11
- package/lib/quota.js +39 -0
- package/lib/region.js +50 -0
- package/lib/shadow.js +81 -0
- package/lib/webhook.js +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// lib/agent-budget.js — per-agent rate cap.
|
|
2
|
+
// ponytail: in-memory counter per (agent, window), thread-safe-ish via timer map.
|
|
3
|
+
|
|
4
|
+
export const AGENT_BUDGET_DEFAULT_WINDOW_MS = 3600_000; // 1h
|
|
5
|
+
export const AGENT_BUDGET_DEFAULT_LIMIT = 0; // 0 = disabled
|
|
6
|
+
export const AGENT_BUDGET_MAX = 50000; // hard ceiling per agent
|
|
7
|
+
|
|
8
|
+
export class AgentBudget {
|
|
9
|
+
constructor({ windowMs = AGENT_BUDGET_DEFAULT_WINDOW_MS, limit = AGENT_BUDGET_DEFAULT_LIMIT } = {}) {
|
|
10
|
+
const w = Number.isFinite(windowMs) && windowMs > 0 ? Math.floor(windowMs) : AGENT_BUDGET_DEFAULT_WINDOW_MS;
|
|
11
|
+
const l = Number.isFinite(limit) && limit >= 0 ? Math.min(AGENT_BUDGET_MAX, Math.floor(limit)) : 0;
|
|
12
|
+
this._windowMs = w;
|
|
13
|
+
this._limit = l;
|
|
14
|
+
this._state = new Map(); // agent -> { hits: number[], windowStart: epochMs }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isEnabled() {
|
|
18
|
+
return this._limit > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Decide if request from this agent is allowed. Returns { allowed, remaining, resetAt }.
|
|
22
|
+
// Records the hit only when allowed.
|
|
23
|
+
check(agentId, now = Date.now()) {
|
|
24
|
+
if (!this.isEnabled()) return { allowed: true, remaining: Infinity, resetAt: null };
|
|
25
|
+
if (!agentId || typeof agentId !== 'string') return { allowed: false, remaining: 0, resetAt: now };
|
|
26
|
+
let s = this._state.get(agentId);
|
|
27
|
+
if (!s) {
|
|
28
|
+
s = { hits: [], windowStart: now };
|
|
29
|
+
this._state.set(agentId, s);
|
|
30
|
+
}
|
|
31
|
+
// Window: prune hits older than windowStart + windowMs
|
|
32
|
+
const cutoff = now - this._windowMs;
|
|
33
|
+
while (s.hits.length > 0 && s.hits[0] < cutoff) s.hits.shift();
|
|
34
|
+
s.windowStart = s.hits.length ? s.hits[0] : now;
|
|
35
|
+
if (s.hits.length >= this._limit) {
|
|
36
|
+
const resetAt = s.hits[0] + this._windowMs;
|
|
37
|
+
return { allowed: false, remaining: 0, resetAt };
|
|
38
|
+
}
|
|
39
|
+
s.hits.push(now);
|
|
40
|
+
return { allowed: true, remaining: this._limit - s.hits.length, resetAt: now + this._windowMs };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Reset single agent or all
|
|
44
|
+
reset(agentId) {
|
|
45
|
+
if (agentId) this._state.delete(agentId);
|
|
46
|
+
else this._state.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Inspect-only: return remaining without recording.
|
|
50
|
+
peek(agentId, now = Date.now()) {
|
|
51
|
+
if (!this.isEnabled()) return { remaining: Infinity, resetAt: null };
|
|
52
|
+
const s = this._state.get(agentId);
|
|
53
|
+
if (!s) return { remaining: this._limit, resetAt: null };
|
|
54
|
+
const cutoff = now - this._windowMs;
|
|
55
|
+
let count = 0;
|
|
56
|
+
for (let i = 0; i < s.hits.length; i++) {
|
|
57
|
+
if (s.hits[i] >= cutoff) count += 1;
|
|
58
|
+
}
|
|
59
|
+
const oldest = s.hits[0];
|
|
60
|
+
return { remaining: this._limit - count, resetAt: oldest ? oldest + this._windowMs : null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
snapshot() {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of this._state) out[k] = { hits: v.hits.length };
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/incident.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -50,9 +50,20 @@ const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
|
50
50
|
const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
51
51
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
52
52
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
53
|
+
const AGENT_BUDGET_PATH = '/dsh-key-rotation/agent-budget';
|
|
54
|
+
const REGIONS_PATH = '/dsh-key-rotation/regions';
|
|
55
|
+
const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
|
|
56
|
+
const SHADOW_PATH = '/dsh-key-rotation/shadow';
|
|
57
|
+
const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
|
|
53
58
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
54
59
|
import { healIdleCooldowns } from './heal.js';
|
|
55
60
|
import { LatencyHistogram } from './histogram.js';
|
|
61
|
+
import { QuotaStore } from './quota.js';
|
|
62
|
+
import { AgentBudget } from './agent-budget.js';
|
|
63
|
+
import { RegionMap } from './region.js';
|
|
64
|
+
import { IncidentReporter } from './incident.js';
|
|
65
|
+
import { ShadowRouter } from './shadow.js';
|
|
66
|
+
import { WebhookSender } from './webhook.js';
|
|
56
67
|
|
|
57
68
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
58
69
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -78,7 +89,23 @@ function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
|
78
89
|
let lastTestCacheRunnerCtx = null;
|
|
79
90
|
const lastTestCache = new LastTestCache();
|
|
80
91
|
const latencyHistogram = new LatencyHistogram();
|
|
81
|
-
|
|
92
|
+
const quotaStore = new QuotaStore();
|
|
93
|
+
const agentBudget = new AgentBudget();
|
|
94
|
+
const regionMap = new RegionMap();
|
|
95
|
+
// IncidentReporter: lazily built when Config provides incidentGitHubToken + incidentGitHubBaseUrl.
|
|
96
|
+
// ponytail: never bake the token into source; repo is hardcoded (this plugin's home repo) but token is per-deploy.
|
|
97
|
+
let incidentReporter = null;
|
|
98
|
+
function ensureIncidentReporter() {
|
|
99
|
+
if (incidentReporter) return incidentReporter;
|
|
100
|
+
const cfg = getConfig();
|
|
101
|
+
const token = cfg ? cfg.incidentGitHubToken : '';
|
|
102
|
+
const baseUrl = cfg ? cfg.incidentGitHubBaseUrl : '';
|
|
103
|
+
if (!token || !baseUrl) return null;
|
|
104
|
+
incidentReporter = new IncidentReporter({ token, baseUrl, repo: 'goodandready/dsh-key-rotation', fetchImpl: globalThis.fetch });
|
|
105
|
+
return incidentReporter;
|
|
106
|
+
}
|
|
107
|
+
const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
|
|
108
|
+
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
82
109
|
function ensureSandboxRunner(ctx) {
|
|
83
110
|
if (sandboxRunner) return sandboxRunner;
|
|
84
111
|
// provider id -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
@@ -128,6 +155,9 @@ export const Config = Schema.object({
|
|
|
128
155
|
selfHealIdleMs: Schema.number().default(3600000),
|
|
129
156
|
latencyEnabled: Schema.boolean().default(true),
|
|
130
157
|
latencyWindow: Schema.number().default(200),
|
|
158
|
+
incidentGitHubToken: Schema.string().default(''),
|
|
159
|
+
incidentGitHubBaseUrl: Schema.string().default(''),
|
|
160
|
+
incidentThreshold: Schema.number().default(5),
|
|
131
161
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
132
162
|
providers: Schema.array(Schema.object({
|
|
133
163
|
provider: Schema.string().required(),
|
|
@@ -531,7 +561,7 @@ export function apply(ctx, config = {}) {
|
|
|
531
561
|
for (const key of [...poolState.keys()]) {
|
|
532
562
|
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
533
563
|
}
|
|
534
|
-
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
564
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
535
565
|
}
|
|
536
566
|
|
|
537
567
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -617,6 +647,21 @@ export function apply(ctx, config = {}) {
|
|
|
617
647
|
reason: { kind: 'error', failure: Object.freeze({ code, message }) },
|
|
618
648
|
});
|
|
619
649
|
|
|
650
|
+
// Latency recording (#6): record successful llm/stream latency per ref.
|
|
651
|
+
// ponytail: only the true success path (finish-chunk). Failures are not recorded.
|
|
652
|
+
let _rotateStartMs = Date.now();
|
|
653
|
+
function recordLatency(pool) {
|
|
654
|
+
try {
|
|
655
|
+
const cfg = getConfig();
|
|
656
|
+
if (!cfg || cfg.latencyEnabled === false) return;
|
|
657
|
+
const ref = pool && pool.state && pool.state.lastUsed;
|
|
658
|
+
if (!ref) return;
|
|
659
|
+
const elapsed = Date.now() - _rotateStartMs;
|
|
660
|
+
if (!Number.isFinite(elapsed) || elapsed < 0) return;
|
|
661
|
+
latencyHistogram.record(ref, elapsed);
|
|
662
|
+
} catch (_) { /* ponytail: never crash */ }
|
|
663
|
+
}
|
|
664
|
+
|
|
620
665
|
// Retry one request on the next pool key when the current key fails with a
|
|
621
666
|
// switchable error before any content chunk. The provider never changes —
|
|
622
667
|
// the resolve patch hands out the next key on each dispatch.
|
|
@@ -624,6 +669,7 @@ export function apply(ctx, config = {}) {
|
|
|
624
669
|
return (async function* () {
|
|
625
670
|
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
626
671
|
let lastFailure = null;
|
|
672
|
+
_rotateStartMs = Date.now();
|
|
627
673
|
|
|
628
674
|
for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
|
|
629
675
|
let yielded = false;
|
|
@@ -700,7 +746,12 @@ export function apply(ctx, config = {}) {
|
|
|
700
746
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
701
747
|
}
|
|
702
748
|
}
|
|
749
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
750
|
+
if (rate && pool.state.lastUsed && Number.isFinite(rate.remaining)) {
|
|
751
|
+
quotaStore.set(pool.state.lastUsed, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
|
|
752
|
+
}
|
|
703
753
|
yield chunk;
|
|
754
|
+
recordLatency(pool);
|
|
704
755
|
return;
|
|
705
756
|
}
|
|
706
757
|
yield chunk;
|
|
@@ -718,14 +769,8 @@ export function apply(ctx, config = {}) {
|
|
|
718
769
|
pool.state.lastExhaustionAt = Date.now();
|
|
719
770
|
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
720
771
|
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
721
|
-
// notify
|
|
722
|
-
|
|
723
|
-
const { notifyWebhook, notifyThreshold } = buildRuntime();
|
|
724
|
-
if (notifyWebhook && pool.state.exhaustionCount >= notifyThreshold) {
|
|
725
|
-
const payload = JSON.stringify({ provider: options.provider, exhaustionCount: pool.state.exhaustionCount, at: pool.state.lastExhaustionAt, keys: pool.refs });
|
|
726
|
-
fetch(notifyWebhook, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload }).catch(()=>{});
|
|
727
|
-
}
|
|
728
|
-
} catch {}
|
|
772
|
+
// notify via extracted helper (see notifyExhaustion above)
|
|
773
|
+
notifyExhaustion(buildRuntime(), pool, { provider: options.provider });
|
|
729
774
|
|
|
730
775
|
yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
|
|
731
776
|
})();
|
|
@@ -993,7 +1038,7 @@ export function apply(ctx, config = {}) {
|
|
|
993
1038
|
if (exhausted) exhaustedAny = true;
|
|
994
1039
|
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
995
1040
|
}
|
|
996
|
-
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll() });
|
|
1041
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: quotaStore.snapshot() });
|
|
997
1042
|
},
|
|
998
1043
|
}), 'dsh-key-rotation: health');
|
|
999
1044
|
|
|
@@ -1055,6 +1100,66 @@ export function apply(ctx, config = {}) {
|
|
|
1055
1100
|
},
|
|
1056
1101
|
}), 'dsh-key-rotation: sandbox cache');
|
|
1057
1102
|
|
|
1103
|
+
// Auto-incident reset (#8).
|
|
1104
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1105
|
+
kind: 'exact',
|
|
1106
|
+
path: INCIDENT_RESET_PATH,
|
|
1107
|
+
handler: (req, res) => {
|
|
1108
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1109
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1110
|
+
readJson(req).then((body) => {
|
|
1111
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1112
|
+
if (provider) incidentReporter.resetCooldown(provider);
|
|
1113
|
+
else incidentReporter.resetCooldown();
|
|
1114
|
+
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1115
|
+
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1116
|
+
},
|
|
1117
|
+
}), 'dsh-key-rotation: incident-reset');
|
|
1118
|
+
|
|
1119
|
+
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1120
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1121
|
+
kind: 'exact',
|
|
1122
|
+
path: WEBHOOK_TEST_PATH,
|
|
1123
|
+
handler: (req, res) => {
|
|
1124
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1125
|
+
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1126
|
+
},
|
|
1127
|
+
}), 'dsh-key-rotation: webhook-test');
|
|
1128
|
+
|
|
1129
|
+
// Shadow A/B sampling snapshot (#9).
|
|
1130
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1131
|
+
kind: 'exact',
|
|
1132
|
+
path: SHADOW_PATH,
|
|
1133
|
+
handler: (req, res) => {
|
|
1134
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1135
|
+
json(res, 200, shadowRouter.snapshot());
|
|
1136
|
+
},
|
|
1137
|
+
}), 'dsh-key-rotation: shadow');
|
|
1138
|
+
|
|
1139
|
+
// Region tags + failover chain (#4).
|
|
1140
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1141
|
+
kind: 'exact',
|
|
1142
|
+
path: REGIONS_PATH,
|
|
1143
|
+
handler: (req, res) => {
|
|
1144
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1145
|
+
const body = regionMap.snapshot();
|
|
1146
|
+
// Add pickFallback hints per provider for inspection.
|
|
1147
|
+
const out = {};
|
|
1148
|
+
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1149
|
+
json(res, 200, out);
|
|
1150
|
+
},
|
|
1151
|
+
}), 'dsh-key-rotation: regions');
|
|
1152
|
+
|
|
1153
|
+
// Per-agent rate budget snapshot (#3).
|
|
1154
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1155
|
+
kind: 'exact',
|
|
1156
|
+
path: AGENT_BUDGET_PATH,
|
|
1157
|
+
handler: (req, res) => {
|
|
1158
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1159
|
+
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1160
|
+
},
|
|
1161
|
+
}), 'dsh-key-rotation: agent-budget');
|
|
1162
|
+
|
|
1058
1163
|
ctx.on('llm/stream', (options, next) => {
|
|
1059
1164
|
if (options[MARKER]) return next();
|
|
1060
1165
|
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
|
@@ -1102,3 +1207,27 @@ export function apply(ctx, config = {}) {
|
|
|
1102
1207
|
});
|
|
1103
1208
|
});
|
|
1104
1209
|
}
|
|
1210
|
+
|
|
1211
|
+
// Notify on exhaustion: webhook + (optional) GitHub incident.
|
|
1212
|
+
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
1213
|
+
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
1214
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender, ensureIncidentReporter }) {
|
|
1215
|
+
if (!runtime || !pool) return;
|
|
1216
|
+
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
1217
|
+
if (count <= 0) return;
|
|
1218
|
+
try {
|
|
1219
|
+
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
1220
|
+
hooks.webhookSender.send(runtime.notifyWebhook, {
|
|
1221
|
+
provider: options.provider,
|
|
1222
|
+
exhaustionCount: count,
|
|
1223
|
+
at: pool.state.lastExhaustionAt,
|
|
1224
|
+
keys: pool.refs,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
|
|
1228
|
+
const reporter = hooks.ensureIncidentReporter();
|
|
1229
|
+
if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
|
|
1230
|
+
}
|
|
1231
|
+
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1232
|
+
}
|
|
1233
|
+
|
package/lib/quota.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// lib/quota.js — quota-remaining persistence per ref.
|
|
2
|
+
// ponytail: pure helpers, snapshot() returns shallow copies.
|
|
3
|
+
|
|
4
|
+
export class QuotaStore {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._data = new Map(); // ref -> { remaining, limit, reset, at }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
set(ref, info) {
|
|
10
|
+
if (!ref) return;
|
|
11
|
+
if (!info || typeof info !== 'object') return;
|
|
12
|
+
const next = {
|
|
13
|
+
remaining: Number.isFinite(info.remaining) ? info.remaining : null,
|
|
14
|
+
limit: Number.isFinite(info.limit) ? info.limit : null,
|
|
15
|
+
reset: Number.isFinite(info.reset) ? info.reset : null,
|
|
16
|
+
at: Number.isFinite(info.at) ? info.at : Date.now(),
|
|
17
|
+
};
|
|
18
|
+
this._data.set(ref, next);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(ref) {
|
|
22
|
+
return this._data.get(ref);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
snapshot() {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [k, v] of this._data) out[k] = v;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clear(ref) {
|
|
32
|
+
if (ref) this._data.delete(ref);
|
|
33
|
+
else this._data.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get size() {
|
|
37
|
+
return this._data.size;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/lib/region.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// lib/region.js — region tag + failover helper.
|
|
2
|
+
// ponytail: simple — providers declare an optional 'region' (string).
|
|
3
|
+
// When the primary provider hits exhaustion AND a same-region fallback is
|
|
4
|
+
// configured, the plugin picks that as next fallback.
|
|
5
|
+
|
|
6
|
+
export const REGION_NONE = '';
|
|
7
|
+
export const REGION_GLOBAL = 'global';
|
|
8
|
+
|
|
9
|
+
export class RegionMap {
|
|
10
|
+
constructor() {
|
|
11
|
+
this._byProvider = new Map(); // provider id -> region
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
set(provider, region = REGION_GLOBAL) {
|
|
15
|
+
if (!provider) return;
|
|
16
|
+
if (!region) region = REGION_GLOBAL;
|
|
17
|
+
this._byProvider.set(provider, region);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(provider) {
|
|
21
|
+
return this._byProvider.get(provider) || REGION_GLOBAL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pick a fallback for `provider`. Returns another provider in the same
|
|
25
|
+
// region if available; otherwise null. Returns null for unknown providers
|
|
26
|
+
// (we don't know their region -> conservative).
|
|
27
|
+
pickFallback(provider) {
|
|
28
|
+
if (!this._byProvider.has(provider)) return null;
|
|
29
|
+
const region = this.get(provider);
|
|
30
|
+
for (const [p, r] of this._byProvider) {
|
|
31
|
+
if (p === provider) continue;
|
|
32
|
+
if (r === region) return p;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
snapshot() {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [k, v] of this._byProvider) out[k] = v;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clear() {
|
|
44
|
+
this._byProvider.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size() {
|
|
48
|
+
return this._byProvider.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/lib/shadow.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// lib/shadow.js — shadow A/B traffic sampling.
|
|
2
|
+
// ponytail: per-provider counter, simple percent gating.
|
|
3
|
+
|
|
4
|
+
export const SHADOW_DEFAULT_PERCENT = 0; // 0 = disabled
|
|
5
|
+
export const SHADOW_BUCKET = 100; // percent base
|
|
6
|
+
|
|
7
|
+
export class ShadowRouter {
|
|
8
|
+
constructor({ primary, secondary, percent = SHADOW_DEFAULT_PERCENT } = {}) {
|
|
9
|
+
this._primary = primary || '';
|
|
10
|
+
this._secondary = secondary || '';
|
|
11
|
+
this._percent = Number.isFinite(percent) && percent > 0 ? Math.min(SHADOW_BUCKET, Math.floor(percent)) : 0;
|
|
12
|
+
this._sent = 0;
|
|
13
|
+
this._shadowed = 0;
|
|
14
|
+
this._latencySumPrimary = 0;
|
|
15
|
+
this._latencySumSecondary = 0;
|
|
16
|
+
this._latencyCountPrimary = 0;
|
|
17
|
+
this._latencyCountSecondary = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isEnabled() {
|
|
21
|
+
return this._percent > 0 && Boolean(this._primary) && Boolean(this._secondary) && this._primary !== this._secondary;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pick(requestHash = Math.random()) {
|
|
25
|
+
if (!this.isEnabled()) return { primary: this._primary, secondary: null, sampled: false };
|
|
26
|
+
// Convert requestHash to [0, SHADOW_BUCKET)
|
|
27
|
+
let h;
|
|
28
|
+
if (typeof requestHash === 'number') {
|
|
29
|
+
h = Math.floor(requestHash * SHADOW_BUCKET);
|
|
30
|
+
} else {
|
|
31
|
+
// Stable hash: fnv1a-lite on string
|
|
32
|
+
let str = String(requestHash);
|
|
33
|
+
let x = 2166136261;
|
|
34
|
+
for (let i = 0; i < str.length; i++) {
|
|
35
|
+
x ^= str.charCodeAt(i);
|
|
36
|
+
x = (x * 16777619) >>> 0;
|
|
37
|
+
}
|
|
38
|
+
h = x % SHADOW_BUCKET;
|
|
39
|
+
}
|
|
40
|
+
const sampled = h < this._percent;
|
|
41
|
+
this._sent += 1;
|
|
42
|
+
if (sampled) this._shadowed += 1;
|
|
43
|
+
return { primary: this._primary, secondary: sampled ? this._secondary : null, sampled };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recordLatency(target, ms) {
|
|
47
|
+
if (!Number.isFinite(ms) || ms < 0) return;
|
|
48
|
+
if (target === this._primary) {
|
|
49
|
+
this._latencySumPrimary += ms;
|
|
50
|
+
this._latencyCountPrimary += 1;
|
|
51
|
+
} else if (target === this._secondary) {
|
|
52
|
+
this._latencySumSecondary += ms;
|
|
53
|
+
this._latencyCountSecondary += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot() {
|
|
58
|
+
const avg = (sum, count) => (count > 0 ? sum / count : null);
|
|
59
|
+
return {
|
|
60
|
+
primary: this._primary,
|
|
61
|
+
secondary: this._secondary,
|
|
62
|
+
percent: this._percent,
|
|
63
|
+
enabled: this.isEnabled(),
|
|
64
|
+
sent: this._sent,
|
|
65
|
+
shadowed: this._shadowed,
|
|
66
|
+
avgLatencyMs: {
|
|
67
|
+
primary: avg(this._latencySumPrimary, this._latencyCountPrimary),
|
|
68
|
+
secondary: avg(this._latencySumSecondary, this._latencyCountSecondary),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
reset() {
|
|
74
|
+
this._sent = 0;
|
|
75
|
+
this._shadowed = 0;
|
|
76
|
+
this._latencySumPrimary = 0;
|
|
77
|
+
this._latencySumSecondary = 0;
|
|
78
|
+
this._latencyCountPrimary = 0;
|
|
79
|
+
this._latencyCountSecondary = 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/webhook.js — webhook sender with throttle.
|
|
2
|
+
// ponytail: minimal. lastSentAt only on success — failures don't block future sends.
|
|
3
|
+
export const WEBHOOK_TIMEOUT_MS = 5000;
|
|
4
|
+
export const WEBHOOK_MIN_INTERVAL_MS = 1000;
|
|
5
|
+
export const WEBHOOK_RETRY_DELAY_MS = 2000;
|
|
6
|
+
|
|
7
|
+
export class WebhookSender {
|
|
8
|
+
constructor({ fetchImpl, minIntervalMs = WEBHOOK_MIN_INTERVAL_MS, timeoutMs = WEBHOOK_TIMEOUT_MS } = {}) {
|
|
9
|
+
if (typeof fetchImpl !== 'function') throw new Error('webhook: fetchImpl required');
|
|
10
|
+
this._fetch = fetchImpl;
|
|
11
|
+
this._minIntervalMs = minIntervalMs;
|
|
12
|
+
this._timeoutMs = timeoutMs;
|
|
13
|
+
this._lastSentAt = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async send(url, payload, now = Date.now()) {
|
|
17
|
+
if (!url || typeof url !== 'string') return { sent: false };
|
|
18
|
+
const last = this._lastSentAt.get(url);
|
|
19
|
+
if (Number.isFinite(last) && now - last < this._minIntervalMs) return { sent: false, throttled: true };
|
|
20
|
+
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
21
|
+
const ctrl = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => ctrl.abort(), this._timeoutMs);
|
|
23
|
+
const doFetch = () => this._fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body,
|
|
27
|
+
signal: ctrl.signal,
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await doFetch();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e && e.name === 'AbortError') return { sent: false, error: 'timeout' };
|
|
35
|
+
return { sent: false, error: 'network' };
|
|
36
|
+
}
|
|
37
|
+
if (res.status >= 500 && res.status < 600) {
|
|
38
|
+
await new Promise((r) => setTimeout(r, WEBHOOK_RETRY_DELAY_MS));
|
|
39
|
+
if (ctrl.signal.aborted) return { sent: false, error: 'timeout' };
|
|
40
|
+
try {
|
|
41
|
+
res = await doFetch();
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return { sent: false, error: 'network' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Only mark on success — failures shouldn't block future sends.
|
|
47
|
+
if (res.ok) this._lastSentAt.set(url, now);
|
|
48
|
+
return { sent: res.ok, status: res.status };
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
reset(url) {
|
|
55
|
+
if (url) this._lastSentAt.delete(url);
|
|
56
|
+
else this._lastSentAt.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
snapshot() {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of this._lastSentAt) out[k] = v;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.23",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|