@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/LICENSE +21 -21
- package/README.md +254 -172
- package/README.ru.md +254 -0
- package/README.zh.md +215 -0
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +129 -52
- package/lib/canary.js +63 -56
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +1156 -1063
- package/lib/concurrency.js +73 -72
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +1850 -1836
- package/lib/pool.js +264 -237
- package/lib/quota-window.js +45 -45
- package/lib/quota.js +39 -39
- package/lib/region.js +50 -50
- package/lib/sandbox.js +117 -117
- package/lib/shadow.js +81 -81
- package/lib/usage-report.js +79 -49
- package/lib/webhook.js +193 -133
- package/package.json +58 -58
package/lib/bucket.js
CHANGED
|
@@ -1,52 +1,129 @@
|
|
|
1
|
-
// lib/bucket.js - per-key RPM token bucket (#192).
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/lib/client-helpers.js
CHANGED
|
@@ -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
|
+
}
|