@goodandready/dsh-key-rotation 0.7.26 → 0.7.28
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 +157 -118
- package/cordis.patch.yml +6 -6
- package/lib/agent-budget.js +68 -68
- package/lib/bucket.js +52 -0
- package/lib/canary.js +56 -56
- package/lib/cascade.js +39 -39
- package/lib/client-helpers.js +21 -21
- package/lib/client.js +95 -5
- package/lib/concurrency.js +72 -72
- package/lib/heal.js +35 -35
- package/lib/histogram.js +66 -66
- package/lib/incident.js +76 -76
- package/lib/index.js +1658 -1402
- package/lib/keycheck.js +31 -0
- package/lib/maintenance.js +64 -0
- package/lib/pool.js +237 -227
- 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 +49 -0
- package/lib/webhook.js +133 -64
- package/package.json +58 -58
package/lib/quota.js
CHANGED
|
@@ -1,39 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,50 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,117 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,81 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// lib/usage-report.js - #209 usage report rows from pool state. Pure helpers.
|
|
2
|
+
const DAY_MS = 86400000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build per-key usage rows for the last `days` ISO days (default 7).
|
|
6
|
+
* Returns [{ ref, requests, cost, active, usageByDay: {day: n} }].
|
|
7
|
+
*/
|
|
8
|
+
export function usageRows(pool, days = 7, now = Date.now()) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const dayKeys = [];
|
|
11
|
+
for (let i = 0; i < Math.max(1, days); i++) dayKeys.push(new Date(now - i * DAY_MS).toISOString().slice(0, 10));
|
|
12
|
+
const refs = pool?.refs ?? [];
|
|
13
|
+
for (const ref of refs) {
|
|
14
|
+
const daysMap = pool.state.usageDays?.get(ref) ?? new Map();
|
|
15
|
+
const costMap = pool.state.costDays?.get(ref) ?? new Map();
|
|
16
|
+
let requests = 0, cost = 0;
|
|
17
|
+
const usageByDay = {};
|
|
18
|
+
for (const d of dayKeys) {
|
|
19
|
+
const r = daysMap.get(d) ?? 0;
|
|
20
|
+
const c = costMap.get(d) ?? 0;
|
|
21
|
+
requests += r;
|
|
22
|
+
cost += c;
|
|
23
|
+
usageByDay[d] = r;
|
|
24
|
+
}
|
|
25
|
+
out.push({
|
|
26
|
+
ref,
|
|
27
|
+
requests,
|
|
28
|
+
cost: Math.round(cost * 100) / 100,
|
|
29
|
+
active: pool.state.lastUsed === ref,
|
|
30
|
+
usageByDay,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** CSV of usage rows: ref,requests,cost,active + per-day columns. */
|
|
37
|
+
export function usageCsv(rows) {
|
|
38
|
+
const dayCols = [...new Set(rows.flatMap((r) => Object.keys(r.usageByDay)))].sort();
|
|
39
|
+
const head = ['ref', 'requests', 'cost', 'active', ...dayCols];
|
|
40
|
+
const esc = (v) => {
|
|
41
|
+
const s = String(v ?? '');
|
|
42
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
43
|
+
};
|
|
44
|
+
const lines = [head.join(',')];
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
lines.push([esc(r.ref), r.requests, r.cost, r.active ? 'yes' : 'no', ...dayCols.map((d) => r.usageByDay[d] ?? 0)].join(','));
|
|
47
|
+
}
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|