@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
- // Bootstrap key pools. The user configures them in the Settings GUI or via
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.20",
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
  }