@goodandready/dsh-key-rotation 0.7.30 → 0.7.32

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/bucket.js CHANGED
@@ -1,52 +1,129 @@
1
- // lib/bucket.js - per-key RPM token bucket (#192).
2
- // ponytail: RPM only - true token counts are not visible before the stream
3
- // starts; wire TPM in when pi-ai surfaces usage on the resolve path.
4
- const WINDOW_MS = 60000;
5
-
6
- /** Sliding-window check: true if `ref` is under `limit` requests/min. */
7
- export function bucketAllow(windows, ref, limit, now = Date.now()) {
8
- if (!limit || limit <= 0) return true;
9
- const cut = now - WINDOW_MS;
10
- const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
11
- if (hits.length >= limit) {
12
- windows.set(ref, hits);
13
- return false;
14
- }
15
- hits.push(now);
16
- windows.set(ref, hits);
17
- return true;
18
- }
19
-
20
- /** Record a hit without checking (use after a successful resolve). */
21
- export function bucketHit(windows, ref, now = Date.now()) {
22
- const cut = now - WINDOW_MS;
23
- const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
24
- hits.push(now);
25
- windows.set(ref, hits);
26
- }
27
-
28
- /** ms until `ref` may retry again (0 = now). */
29
- export function bucketRetryMs(windows, ref, limit, now = Date.now()) {
30
- if (!limit || limit <= 0) return 0;
31
- const hits = (windows.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
32
- if (hits.length < limit) return 0;
33
- return Math.max(0, hits[0] + WINDOW_MS - now);
34
- }
35
-
36
- /** Drop state for refs that no longer exist. */
37
- export function bucketSweep(windows, liveRefs) {
38
- for (const ref of [...windows.keys()]) {
39
- if (!liveRefs.has(ref)) windows.delete(ref);
40
- }
41
- }
42
-
43
- /** #210: snapshot for /status - used/remaining/resetMs for one ref. */
44
- export function bucketInfo(windows, ref, limit, now = Date.now()) {
45
- if (!limit || limit <= 0) return null;
46
- const hits = (windows?.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
47
- return {
48
- used: hits.length,
49
- remaining: Math.max(0, limit - hits.length),
50
- resetMs: hits.length ? Math.max(0, hits[0] + WINDOW_MS - now) : 0,
51
- };
52
- }
1
+ // lib/bucket.js - per-key RPM token bucket (#192) + O(1) accumulator and adaptive tuning.
2
+ const WINDOW_MS = 60000;
3
+
4
+ /**
5
+ * O(1) Mathematical Token Bucket Accumulator.
6
+ * tokens = min(capacity, tokens + (now - lastRefill) * refillRate)
7
+ */
8
+ export class TokenBucketAccumulator {
9
+ constructor(capacity, windowMs = WINDOW_MS, now = Date.now()) {
10
+ this.capacity = Math.max(1, capacity);
11
+ this.tokens = this.capacity;
12
+ this.windowMs = windowMs;
13
+ this.refillRate = this.capacity / this.windowMs; // tokens per ms
14
+ this.lastRefill = now;
15
+ }
16
+
17
+ refill(now = Date.now()) {
18
+ const elapsed = Math.max(0, now - this.lastRefill);
19
+ if (elapsed > 0) {
20
+ this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
21
+ this.lastRefill = now;
22
+ }
23
+ }
24
+
25
+ allow(cost = 1, now = Date.now()) {
26
+ this.refill(now);
27
+ if (this.tokens >= cost) {
28
+ this.tokens -= cost;
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ retryMs(cost = 1, now = Date.now()) {
35
+ this.refill(now);
36
+ if (this.tokens >= cost) return 0;
37
+ const needed = cost - this.tokens;
38
+ return Math.ceil(needed / this.refillRate);
39
+ }
40
+
41
+ updateCapacity(newCapacity, now = Date.now()) {
42
+ this.refill(now);
43
+ const prevCap = this.capacity;
44
+ this.capacity = Math.max(1, newCapacity);
45
+ this.refillRate = this.capacity / this.windowMs;
46
+ // Scale current tokens proportionally or clamp
47
+ this.tokens = Math.min(this.capacity, Math.max(0, this.tokens + (this.capacity - prevCap)));
48
+ }
49
+
50
+ info(now = Date.now()) {
51
+ this.refill(now);
52
+ const used = Math.max(0, Math.round(this.capacity - this.tokens));
53
+ const remaining = Math.max(0, Math.floor(this.tokens));
54
+ return {
55
+ used,
56
+ remaining,
57
+ resetMs: this.retryMs(1, now),
58
+ capacity: this.capacity,
59
+ };
60
+ }
61
+ }
62
+
63
+ /** Sliding-window check: true if `ref` is under `limit` requests/min. */
64
+ export function bucketAllow(windows, ref, limit, now = Date.now()) {
65
+ if (!limit || limit <= 0) return true;
66
+ const cut = now - WINDOW_MS;
67
+ const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
68
+ if (hits.length >= limit) {
69
+ windows.set(ref, hits);
70
+ return false;
71
+ }
72
+ hits.push(now);
73
+ windows.set(ref, hits);
74
+ return true;
75
+ }
76
+
77
+ /** Record a hit without checking (use after a successful resolve). */
78
+ export function bucketHit(windows, ref, now = Date.now()) {
79
+ const cut = now - WINDOW_MS;
80
+ const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
81
+ hits.push(now);
82
+ windows.set(ref, hits);
83
+ }
84
+
85
+ /** ms until `ref` may retry again (0 = now). */
86
+ export function bucketRetryMs(windows, ref, limit, now = Date.now()) {
87
+ if (!limit || limit <= 0) return 0;
88
+ const hits = (windows.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
89
+ if (hits.length < limit) return 0;
90
+ return Math.max(0, hits[0] + WINDOW_MS - now);
91
+ }
92
+
93
+ /** Drop state for refs that no longer exist. */
94
+ export function bucketSweep(windows, liveRefs) {
95
+ for (const ref of [...windows.keys()]) {
96
+ if (!liveRefs.has(ref)) windows.delete(ref);
97
+ }
98
+ }
99
+
100
+ /** Snapshot for /status - used/remaining/resetMs for one ref. */
101
+ export function bucketInfo(windows, ref, limit, now = Date.now()) {
102
+ if (!limit || limit <= 0) return null;
103
+ const hits = (windows?.get(ref) ?? []).filter((t) => t > now - WINDOW_MS);
104
+ return {
105
+ used: hits.length,
106
+ remaining: Math.max(0, limit - hits.length),
107
+ resetMs: hits.length ? Math.max(0, hits[0] + WINDOW_MS - now) : 0,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Adaptive limit computation from HTTP headers with manual limit precedence.
113
+ * If manualLimit is given (> 0), it acts as the upper ceiling.
114
+ * If adaptive is enabled and upstream reports a lower limit/remaining, adapt downwards.
115
+ */
116
+ export function computeEffectiveLimit(headerRateLimit, manualLimit, adaptiveEnabled = true) {
117
+ const manual = (Number.isFinite(manualLimit) && manualLimit > 0) ? manualLimit : null;
118
+ if (!adaptiveEnabled || !headerRateLimit) {
119
+ return manual;
120
+ }
121
+ const headerLimit = headerRateLimit.limit;
122
+ if (Number.isFinite(headerLimit) && headerLimit > 0) {
123
+ if (manual) {
124
+ return Math.min(manual, headerLimit); // manual acts as upper ceiling
125
+ }
126
+ return headerLimit;
127
+ }
128
+ return manual;
129
+ }
package/lib/canary.js CHANGED
@@ -1,56 +1,63 @@
1
- // canary.js — canary probing before releasing a key from cooldown (issue #196).
2
-
3
- export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
- export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
-
6
- export class CanaryProber {
7
- constructor(opts) {
8
- opts = opts || {};
9
- if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
- this._runner = opts.sandboxRunner;
11
- this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
- this._results = new Map();
13
- this._inProgress = new Set();
14
- }
15
-
16
- get intervalMs() { return this._intervalMs; }
17
-
18
- async probe(ref, key) {
19
- if (!ref || this._inProgress.has(ref)) return null;
20
- this._inProgress.add(ref);
21
- try {
22
- const result = await this._runner.probeModels(ref, key);
23
- this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
24
- return result;
25
- } catch (e) {
26
- return { ok: false, code: 'error', at: Date.now() };
27
- } finally {
28
- this._inProgress.delete(ref);
29
- }
30
- }
31
-
32
- lastResult(ref) {
33
- return this._results.get(ref) || null;
34
- }
35
-
36
- isHealthy(ref) {
37
- const r = this._results.get(ref);
38
- return Boolean(r && r.ok);
39
- }
40
-
41
- clear(ref) {
42
- if (ref) {
43
- this._results.delete(ref);
44
- this._inProgress.delete(ref);
45
- } else {
46
- this._results.clear();
47
- this._inProgress.clear();
48
- }
49
- }
50
-
51
- snapshot() {
52
- const out = {};
53
- for (const [k, v] of this._results) out[k] = v;
54
- return out;
55
- }
56
- }
1
+ // canary.js — canary probing before releasing a key from cooldown (issue #196, #7).
2
+
3
+ export const CANARY_PROBE_TIMEOUT_MS = 5000;
4
+ export const CANARY_DEFAULT_INTERVAL_MS = 30 * 1000;
5
+
6
+ export class CanaryProber {
7
+ constructor(opts) {
8
+ opts = opts || {};
9
+ if (!opts.sandboxRunner) throw new Error('canary: sandboxRunner required');
10
+ this._runner = opts.sandboxRunner;
11
+ this._intervalMs = opts.intervalMs || CANARY_DEFAULT_INTERVAL_MS;
12
+ this._probeTargetModel = Boolean(opts.probeTargetModel);
13
+ this._results = new Map();
14
+ this._inProgress = new Set();
15
+ }
16
+
17
+ get intervalMs() { return this._intervalMs; }
18
+ get probeTargetModel() { return this._probeTargetModel; }
19
+
20
+ async probe(ref, key, targetModel = null) {
21
+ if (!ref || this._inProgress.has(ref)) return null;
22
+ this._inProgress.add(ref);
23
+ try {
24
+ let result;
25
+ if (this._probeTargetModel && targetModel && typeof this._runner.probeChatCompletion === 'function') {
26
+ result = await this._runner.probeChatCompletion(ref, key, targetModel);
27
+ } else {
28
+ result = await this._runner.probeModels(ref, key);
29
+ }
30
+ this._results.set(ref, Object.assign({}, result, { at: Date.now() }));
31
+ return result;
32
+ } catch (e) {
33
+ return { ok: false, code: 'error', at: Date.now() };
34
+ } finally {
35
+ this._inProgress.delete(ref);
36
+ }
37
+ }
38
+
39
+ lastResult(ref) {
40
+ return this._results.get(ref) || null;
41
+ }
42
+
43
+ isHealthy(ref) {
44
+ const r = this._results.get(ref);
45
+ return Boolean(r && r.ok);
46
+ }
47
+
48
+ clear(ref) {
49
+ if (ref) {
50
+ this._results.delete(ref);
51
+ this._inProgress.delete(ref);
52
+ } else {
53
+ this._results.clear();
54
+ this._inProgress.clear();
55
+ }
56
+ }
57
+
58
+ snapshot() {
59
+ const out = {};
60
+ for (const [k, v] of this._results) out[k] = v;
61
+ return out;
62
+ }
63
+ }
@@ -1,21 +1,21 @@
1
- export function formatAgo(t, at) {
2
- if (!at) return '';
3
- const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
4
- if (sec < 60) return t('justNow');
5
- if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
6
- return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
7
- }
8
-
9
- export function nextKeyRef(providerId, existingKeys, allRefs) {
10
- const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
11
- const base = fromExisting
12
- ? fromExisting.replace(/_\d+$/, '')
13
- : String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
14
- const taken = new Set(allRefs);
15
- if (!taken.has(base)) return base;
16
- for (let n = 2; n < 1000; n++) {
17
- const candidate = base + '_' + n;
18
- if (!taken.has(candidate)) return candidate;
19
- }
20
- return base + '_' + Date.now();
21
- }
1
+ export function formatAgo(t, at) {
2
+ if (!at) return '';
3
+ const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
4
+ if (sec < 60) return t('justNow');
5
+ if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
6
+ return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
7
+ }
8
+
9
+ export function nextKeyRef(providerId, existingKeys, allRefs) {
10
+ const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
11
+ const base = fromExisting
12
+ ? fromExisting.replace(/_\d+$/, '')
13
+ : String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
14
+ const taken = new Set(allRefs);
15
+ if (!taken.has(base)) return base;
16
+ for (let n = 2; n < 1000; n++) {
17
+ const candidate = base + '_' + n;
18
+ if (!taken.has(candidate)) return candidate;
19
+ }
20
+ return base + '_' + Date.now();
21
+ }