@goodandready/dsh-key-rotation 0.7.20 → 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/heal.js +35 -0
- package/lib/histogram.js +66 -0
- package/lib/incident.js +76 -0
- package/lib/index.js +186 -3
- package/lib/quota.js +39 -0
- package/lib/region.js +50 -0
- package/lib/sandbox.js +117 -0
- package/lib/shadow.js +81 -0
- package/lib/webhook.js +64 -0
- package/package.json +2 -2
|
@@ -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/heal.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/lib/histogram.js
ADDED
|
@@ -0,0 +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
|
+
}
|
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
|
@@ -49,6 +49,21 @@ const RESET_PATH = '/dsh-key-rotation/reset';
|
|
|
49
49
|
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
|
+
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';
|
|
58
|
+
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
59
|
+
import { healIdleCooldowns } from './heal.js';
|
|
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';
|
|
52
67
|
|
|
53
68
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
54
69
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -70,7 +85,46 @@ function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
|
70
85
|
// treat pre-content failures whose message matches these patterns as
|
|
71
86
|
// switchable even when the code is not in `switchCodes`.
|
|
72
87
|
|
|
73
|
-
//
|
|
88
|
+
// Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
|
|
89
|
+
let lastTestCacheRunnerCtx = null;
|
|
90
|
+
const lastTestCache = new LastTestCache();
|
|
91
|
+
const latencyHistogram = new LatencyHistogram();
|
|
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 });
|
|
99
|
+
function ensureSandboxRunner(ctx) {
|
|
100
|
+
if (sandboxRunner) return sandboxRunner;
|
|
101
|
+
// provider id -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
102
|
+
function resolveBaseUrl(provider) {
|
|
103
|
+
try {
|
|
104
|
+
const ns = ctx.get(PIAI_NS);
|
|
105
|
+
const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
|
|
106
|
+
if (!Array.isArray(list)) return null;
|
|
107
|
+
// ponytail: match by id OR name OR alias; pick first hit
|
|
108
|
+
const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
|
|
109
|
+
const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
|
|
110
|
+
return base ? String(base) : null;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
|
|
116
|
+
return sandboxRunner;
|
|
117
|
+
}
|
|
118
|
+
async function probeRef(ref, key) {
|
|
119
|
+
// ref may be like "PROVIDER/KEY_NAME" — for sandbox we only care about the credential ref
|
|
120
|
+
// (the resolveBaseUrl uses the full provider id; ref can carry any string)
|
|
121
|
+
const runner = ensureSandboxRunner(lastTestCacheRunnerCtx);
|
|
122
|
+
const result = await runner.probeModels(ref, key);
|
|
123
|
+
lastTestCache.set(ref, { ...result, at: Date.now() });
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// // Bootstrap key pools. The user configures them in the Settings GUI or via
|
|
74
128
|
// the dsh profile bundle config; the plugin itself ships no provider defaults
|
|
75
129
|
// so it does not bind to any specific installation. Empty array means: until
|
|
76
130
|
// the user adds a pool, no rotation happens, and every provider falls back to
|
|
@@ -87,6 +141,10 @@ export const Config = Schema.object({
|
|
|
87
141
|
backupIntervalMs: Schema.number().default(86400000),
|
|
88
142
|
backupKeep: Schema.number().default(7),
|
|
89
143
|
rotationScheduleDays: Schema.number().default(0),
|
|
144
|
+
selfHealCooldown: Schema.boolean().default(true),
|
|
145
|
+
selfHealIdleMs: Schema.number().default(3600000),
|
|
146
|
+
latencyEnabled: Schema.boolean().default(true),
|
|
147
|
+
latencyWindow: Schema.number().default(200),
|
|
90
148
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
91
149
|
providers: Schema.array(Schema.object({
|
|
92
150
|
provider: Schema.string().required(),
|
|
@@ -255,6 +313,30 @@ export function apply(ctx, config = {}) {
|
|
|
255
313
|
// profile does not need a second copy of that package.)
|
|
256
314
|
let getConfig = () => config;
|
|
257
315
|
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
316
|
+
lastTestCacheRunnerCtx = ctx;
|
|
317
|
+
// Cache should not survive profile restarts (apply is called per reload).
|
|
318
|
+
// We deliberately do NOT clear on every apply — that would wipe badges when
|
|
319
|
+
// the user is just typing in the settings card. Re-init only on true reload.
|
|
320
|
+
ensureSandboxRunner(ctx);
|
|
321
|
+
|
|
322
|
+
// Self-healing idle cooldowns: every 60s, lift expired cooldowns for keys
|
|
323
|
+
// that have been idle for selfHealIdleMs (default 1h). ponytail: small
|
|
324
|
+
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
325
|
+
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
326
|
+
// fresh config on every tick. Naive but correct: 60s cadence is cheap.
|
|
327
|
+
const selfHealTimer = setInterval(() => {
|
|
328
|
+
const cfg = getConfig();
|
|
329
|
+
if (!cfg || cfg.selfHealCooldown === false) return;
|
|
330
|
+
try {
|
|
331
|
+
const idle = Number.isFinite(cfg.selfHealIdleMs) && cfg.selfHealIdleMs > 0 ? cfg.selfHealIdleMs : 3600000;
|
|
332
|
+
const providers = Array.isArray(cfg.providers) ? cfg.providers : [];
|
|
333
|
+
const pools = providers
|
|
334
|
+
.map((p) => buildRuntime().providerToPool.get(p.provider))
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
healIdleCooldowns(pools, idle);
|
|
337
|
+
} catch (_) { /* ponytail: never crash the timer */ }
|
|
338
|
+
}, 60000);
|
|
339
|
+
if (typeof selfHealTimer.unref === 'function') selfHealTimer.unref();
|
|
258
340
|
|
|
259
341
|
// Dashboard widget now lives in client.js (mountDashboard, see issue #152).
|
|
260
342
|
const DASH_HTML = '';
|
|
@@ -552,6 +634,21 @@ export function apply(ctx, config = {}) {
|
|
|
552
634
|
reason: { kind: 'error', failure: Object.freeze({ code, message }) },
|
|
553
635
|
});
|
|
554
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
|
+
|
|
555
652
|
// Retry one request on the next pool key when the current key fails with a
|
|
556
653
|
// switchable error before any content chunk. The provider never changes —
|
|
557
654
|
// the resolve patch hands out the next key on each dispatch.
|
|
@@ -559,6 +656,7 @@ export function apply(ctx, config = {}) {
|
|
|
559
656
|
return (async function* () {
|
|
560
657
|
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
561
658
|
let lastFailure = null;
|
|
659
|
+
_rotateStartMs = Date.now();
|
|
562
660
|
|
|
563
661
|
for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
|
|
564
662
|
let yielded = false;
|
|
@@ -635,7 +733,12 @@ export function apply(ctx, config = {}) {
|
|
|
635
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`);
|
|
636
734
|
}
|
|
637
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
|
+
}
|
|
638
740
|
yield chunk;
|
|
741
|
+
recordLatency(pool);
|
|
639
742
|
return;
|
|
640
743
|
}
|
|
641
744
|
yield chunk;
|
|
@@ -928,7 +1031,7 @@ export function apply(ctx, config = {}) {
|
|
|
928
1031
|
if (exhausted) exhaustedAny = true;
|
|
929
1032
|
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
930
1033
|
}
|
|
931
|
-
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
|
|
1034
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: quotaStore.snapshot() });
|
|
932
1035
|
},
|
|
933
1036
|
}), 'dsh-key-rotation: health');
|
|
934
1037
|
|
|
@@ -944,11 +1047,11 @@ export function apply(ctx, config = {}) {
|
|
|
944
1047
|
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
945
1048
|
// Optional value for pre-save validation (issue #118)
|
|
946
1049
|
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
1050
|
+
const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
|
|
947
1051
|
const base = ctx.get('credentials');
|
|
948
1052
|
try {
|
|
949
1053
|
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
950
1054
|
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
951
|
-
// Pre-save validation: check the provided value directly (issue #118)
|
|
952
1055
|
const effectiveValue = testValue || hit?.value;
|
|
953
1056
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
954
1057
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
@@ -960,6 +1063,16 @@ export function apply(ctx, config = {}) {
|
|
|
960
1063
|
const ev = envValue(ref);
|
|
961
1064
|
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
962
1065
|
}
|
|
1066
|
+
// sandbox probe (models is free; chat is hook-only, see sandbox.js)
|
|
1067
|
+
if (probe) {
|
|
1068
|
+
const keyForProbe = effectiveValue;
|
|
1069
|
+
const runner = ensureSandboxRunner(ctx);
|
|
1070
|
+
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
1071
|
+
const cached = { ...result, at: Date.now() };
|
|
1072
|
+
lastTestCache.set(ref, cached);
|
|
1073
|
+
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
963
1076
|
json(res, 200, { ok: true, ref, tail, source });
|
|
964
1077
|
} catch (e) {
|
|
965
1078
|
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
@@ -970,6 +1083,76 @@ export function apply(ctx, config = {}) {
|
|
|
970
1083
|
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
971
1084
|
// to a configured key pool; pass everything else (and internal dispatches)
|
|
972
1085
|
// straight through.
|
|
1086
|
+
// Read-only cache snapshot for clients (badge polling).
|
|
1087
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1088
|
+
kind: 'exact',
|
|
1089
|
+
path: SANDBOX_CACHE_PATH,
|
|
1090
|
+
handler: (req, res) => {
|
|
1091
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
|
|
1092
|
+
json(res, 200, lastTestCache.snapshot());
|
|
1093
|
+
},
|
|
1094
|
+
}), 'dsh-key-rotation: sandbox cache');
|
|
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
|
+
|
|
973
1156
|
ctx.on('llm/stream', (options, next) => {
|
|
974
1157
|
if (options[MARKER]) return next();
|
|
975
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/sandbox.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// sandbox.js — probe sandbox-test runner + last-test cache.
|
|
2
|
+
// Ponytail-mode (full): simplest correct path.
|
|
3
|
+
// YAGNI: chat completions is a hook (not-implemented).
|
|
4
|
+
// In-memory only; restart dsh-web = clear cache.
|
|
5
|
+
|
|
6
|
+
export const PROBE_MODELS_TIMEOUT_MS = 5000;
|
|
7
|
+
export const PROBE_RETRY_DELAY_MS = 1000;
|
|
8
|
+
export const LAST_TEST_MAX = 200;
|
|
9
|
+
|
|
10
|
+
export class LastTestCache {
|
|
11
|
+
constructor(max = LAST_TEST_MAX) {
|
|
12
|
+
this._max = max;
|
|
13
|
+
this._data = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(ref, result) {
|
|
17
|
+
if (!ref || !result) return;
|
|
18
|
+
if (this._data.has(ref)) this._data.delete(ref);
|
|
19
|
+
this._data.set(ref, result);
|
|
20
|
+
while (this._data.size > this._max) {
|
|
21
|
+
const first = this._data.keys().next().value;
|
|
22
|
+
if (first === undefined) break;
|
|
23
|
+
this._data.delete(first);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(ref) {
|
|
28
|
+
return this._data.get(ref);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
snapshot() {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of this._data) out[k] = v;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clear() {
|
|
38
|
+
this._data.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get size() {
|
|
42
|
+
return this._data.size;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classifyStatus(status) {
|
|
47
|
+
if (status === 401 || status === 403) return 'auth';
|
|
48
|
+
if (status === 404) return 'not-found';
|
|
49
|
+
if (status === 429) return 'rate-limit';
|
|
50
|
+
if (status >= 500 && status < 600) return 'server';
|
|
51
|
+
return `http-${status}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SandboxRunner {
|
|
55
|
+
constructor({ fetchImpl, resolveBaseUrl, log = () => {} } = {}) {
|
|
56
|
+
if (typeof fetchImpl !== 'function') throw new Error('sandbox: fetchImpl required');
|
|
57
|
+
if (typeof resolveBaseUrl !== 'function') throw new Error('sandbox: resolveBaseUrl required');
|
|
58
|
+
this._fetch = fetchImpl;
|
|
59
|
+
this._resolveBaseUrl = resolveBaseUrl;
|
|
60
|
+
this._log = log;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async probeModels(ref, key) {
|
|
64
|
+
if (!ref || typeof key !== 'string' || key.length === 0) {
|
|
65
|
+
return { ok: false, code: 'no-credential', latencyMs: 0 };
|
|
66
|
+
}
|
|
67
|
+
const baseUrl = await this._resolveBaseUrl(ref);
|
|
68
|
+
if (!baseUrl) {
|
|
69
|
+
return { ok: false, code: 'no-baseurl', latencyMs: 0 };
|
|
70
|
+
}
|
|
71
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/models`;
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => ctrl.abort(), PROBE_MODELS_TIMEOUT_MS);
|
|
75
|
+
const doFetch = () => this._fetch(url, {
|
|
76
|
+
method: 'GET',
|
|
77
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
78
|
+
signal: ctrl.signal,
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
let res;
|
|
82
|
+
try {
|
|
83
|
+
res = await doFetch();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
86
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
87
|
+
}
|
|
88
|
+
// ponytail: 1 retry on 5xx — naive; classifier refines if needed
|
|
89
|
+
if (res.status >= 500 && res.status < 600) {
|
|
90
|
+
await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
|
91
|
+
if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
92
|
+
try {
|
|
93
|
+
res = await doFetch();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
96
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const status = res.status;
|
|
100
|
+
if (status >= 200 && status < 300) {
|
|
101
|
+
let modelsCount = 0;
|
|
102
|
+
try {
|
|
103
|
+
const body = await res.json();
|
|
104
|
+
modelsCount = Array.isArray(body && body.data) ? body.data.length : 0;
|
|
105
|
+
} catch (_) { /* not json */ }
|
|
106
|
+
return { ok: true, code: 'ok', latencyMs: Date.now() - started, modelsCount };
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, code: classifyStatus(status), latencyMs: Date.now() - started };
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async probeChat(_ref, _key) {
|
|
115
|
+
return { ok: false, code: 'not-implemented', latencyMs: 0 };
|
|
116
|
+
}
|
|
117
|
+
}
|
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",
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node --test test/*.test.js"
|
|
56
|
+
"test": "node --test test/*.test.js test/*.test.mjs"
|
|
57
57
|
}
|
|
58
58
|
}
|