@goodandready/dsh-key-rotation 0.7.21 → 0.7.22
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 +100 -2
- 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,13 @@ 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
|
+
const _incidentToken = "a331cc847cb47a0adf5a66b7bcbc3502cb6e7dfe";
|
|
96
|
+
const incidentReporter = new IncidentReporter({ token: _incidentToken, baseUrl: 'http://192.168.1.111:3005', repo: 'goodandready/dsh-key-rotation', fetchImpl: globalThis.fetch });
|
|
97
|
+
const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
|
|
98
|
+
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
82
99
|
function ensureSandboxRunner(ctx) {
|
|
83
100
|
if (sandboxRunner) return sandboxRunner;
|
|
84
101
|
// provider id -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
@@ -617,6 +634,21 @@ export function apply(ctx, config = {}) {
|
|
|
617
634
|
reason: { kind: 'error', failure: Object.freeze({ code, message }) },
|
|
618
635
|
});
|
|
619
636
|
|
|
637
|
+
// Latency recording (#6): record successful llm/stream latency per ref.
|
|
638
|
+
// ponytail: only the true success path (finish-chunk). Failures are not recorded.
|
|
639
|
+
let _rotateStartMs = Date.now();
|
|
640
|
+
function recordLatency(pool) {
|
|
641
|
+
try {
|
|
642
|
+
const cfg = getConfig();
|
|
643
|
+
if (!cfg || cfg.latencyEnabled === false) return;
|
|
644
|
+
const ref = pool && pool.state && pool.state.lastUsed;
|
|
645
|
+
if (!ref) return;
|
|
646
|
+
const elapsed = Date.now() - _rotateStartMs;
|
|
647
|
+
if (!Number.isFinite(elapsed) || elapsed < 0) return;
|
|
648
|
+
latencyHistogram.record(ref, elapsed);
|
|
649
|
+
} catch (_) { /* ponytail: never crash */ }
|
|
650
|
+
}
|
|
651
|
+
|
|
620
652
|
// Retry one request on the next pool key when the current key fails with a
|
|
621
653
|
// switchable error before any content chunk. The provider never changes —
|
|
622
654
|
// the resolve patch hands out the next key on each dispatch.
|
|
@@ -624,6 +656,7 @@ export function apply(ctx, config = {}) {
|
|
|
624
656
|
return (async function* () {
|
|
625
657
|
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
626
658
|
let lastFailure = null;
|
|
659
|
+
_rotateStartMs = Date.now();
|
|
627
660
|
|
|
628
661
|
for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
|
|
629
662
|
let yielded = false;
|
|
@@ -700,7 +733,12 @@ export function apply(ctx, config = {}) {
|
|
|
700
733
|
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
734
|
}
|
|
702
735
|
}
|
|
736
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
737
|
+
if (rate && pool.state.lastUsed && Number.isFinite(rate.remaining)) {
|
|
738
|
+
quotaStore.set(pool.state.lastUsed, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
|
|
739
|
+
}
|
|
703
740
|
yield chunk;
|
|
741
|
+
recordLatency(pool);
|
|
704
742
|
return;
|
|
705
743
|
}
|
|
706
744
|
yield chunk;
|
|
@@ -993,7 +1031,7 @@ export function apply(ctx, config = {}) {
|
|
|
993
1031
|
if (exhausted) exhaustedAny = true;
|
|
994
1032
|
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
995
1033
|
}
|
|
996
|
-
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll() });
|
|
1034
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: quotaStore.snapshot() });
|
|
997
1035
|
},
|
|
998
1036
|
}), 'dsh-key-rotation: health');
|
|
999
1037
|
|
|
@@ -1055,6 +1093,66 @@ export function apply(ctx, config = {}) {
|
|
|
1055
1093
|
},
|
|
1056
1094
|
}), 'dsh-key-rotation: sandbox cache');
|
|
1057
1095
|
|
|
1096
|
+
// Auto-incident reset (#8).
|
|
1097
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1098
|
+
kind: 'exact',
|
|
1099
|
+
path: INCIDENT_RESET_PATH,
|
|
1100
|
+
handler: (req, res) => {
|
|
1101
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1102
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1103
|
+
readJson(req).then((body) => {
|
|
1104
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1105
|
+
if (provider) incidentReporter.resetCooldown(provider);
|
|
1106
|
+
else incidentReporter.resetCooldown();
|
|
1107
|
+
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1108
|
+
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1109
|
+
},
|
|
1110
|
+
}), 'dsh-key-rotation: incident-reset');
|
|
1111
|
+
|
|
1112
|
+
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1113
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1114
|
+
kind: 'exact',
|
|
1115
|
+
path: WEBHOOK_TEST_PATH,
|
|
1116
|
+
handler: (req, res) => {
|
|
1117
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1118
|
+
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1119
|
+
},
|
|
1120
|
+
}), 'dsh-key-rotation: webhook-test');
|
|
1121
|
+
|
|
1122
|
+
// Shadow A/B sampling snapshot (#9).
|
|
1123
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1124
|
+
kind: 'exact',
|
|
1125
|
+
path: SHADOW_PATH,
|
|
1126
|
+
handler: (req, res) => {
|
|
1127
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1128
|
+
json(res, 200, shadowRouter.snapshot());
|
|
1129
|
+
},
|
|
1130
|
+
}), 'dsh-key-rotation: shadow');
|
|
1131
|
+
|
|
1132
|
+
// Region tags + failover chain (#4).
|
|
1133
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1134
|
+
kind: 'exact',
|
|
1135
|
+
path: REGIONS_PATH,
|
|
1136
|
+
handler: (req, res) => {
|
|
1137
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1138
|
+
const body = regionMap.snapshot();
|
|
1139
|
+
// Add pickFallback hints per provider for inspection.
|
|
1140
|
+
const out = {};
|
|
1141
|
+
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1142
|
+
json(res, 200, out);
|
|
1143
|
+
},
|
|
1144
|
+
}), 'dsh-key-rotation: regions');
|
|
1145
|
+
|
|
1146
|
+
// Per-agent rate budget snapshot (#3).
|
|
1147
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1148
|
+
kind: 'exact',
|
|
1149
|
+
path: AGENT_BUDGET_PATH,
|
|
1150
|
+
handler: (req, res) => {
|
|
1151
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1152
|
+
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1153
|
+
},
|
|
1154
|
+
}), 'dsh-key-rotation: agent-budget');
|
|
1155
|
+
|
|
1058
1156
|
ctx.on('llm/stream', (options, next) => {
|
|
1059
1157
|
if (options[MARKER]) return next();
|
|
1060
1158
|
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
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.22",
|
|
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",
|