@goodandready/dsh-key-rotation 0.7.35 → 0.7.37
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/README.md +16 -0
- package/README.ru.md +16 -0
- package/README.zh.md +16 -0
- package/lib/client.js +27 -6
- package/lib/index.js +136 -334
- package/lib/pool.js +110 -1
- package/package.json +1 -1
- package/lib/agent-budget.js +0 -68
- package/lib/canary.js +0 -63
- package/lib/incident.js +0 -76
- package/lib/maintenance.js +0 -64
- package/lib/region.js +0 -50
- package/lib/shadow.js +0 -81
package/lib/pool.js
CHANGED
|
@@ -44,6 +44,10 @@ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
|
|
|
44
44
|
/\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
|
|
45
45
|
/\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
|
|
46
46
|
/\bbilling\b/i,
|
|
47
|
+
/\bresource[\s_-]+exhausted\b/i,
|
|
48
|
+
/\bcapacity[\s_-]+(?:limit|exceeded|reached)\b/i,
|
|
49
|
+
/\bfree[\s_-]+tier[\s_-]+(?:limit|exceeded)\b/i,
|
|
50
|
+
/\bconcurrent[\s_-]+(?:requests?|limit)[\s_-]+exceeded\b/i,
|
|
47
51
|
/\b429\b|\b5\d\d\b/i,
|
|
48
52
|
/\btime(?:d)?\s*out\b|timeout/i,
|
|
49
53
|
/\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
|
|
@@ -52,6 +56,7 @@ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
|
|
|
52
56
|
/\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
|
|
53
57
|
/\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
|
|
54
58
|
/\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
|
|
59
|
+
/\b(?:overloaded|server[\s_-]+busy)\b/i,
|
|
55
60
|
].map((r) => r.source).join('|'), 'i');
|
|
56
61
|
|
|
57
62
|
/** Default switch codes used by lib/index.js. */
|
|
@@ -261,4 +266,108 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
261
266
|
if (rate.remaining === undefined) return false;
|
|
262
267
|
if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
|
|
263
268
|
return rate.remaining <= 0;
|
|
264
|
-
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Unified check whether an error or payload represents a switchable failure.
|
|
273
|
+
* Prioritizes explicit HTTP status codes and gRPC codes before text matching.
|
|
274
|
+
*/
|
|
275
|
+
export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAULT_SWITCH_CODES)) {
|
|
276
|
+
if (!failureOrPayload) return false;
|
|
277
|
+
const failure = failureOrPayload.failure ?? failureOrPayload;
|
|
278
|
+
const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
|
|
279
|
+
const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
|
|
280
|
+
const message = String(failure.message ?? failureOrPayload.message ?? '');
|
|
281
|
+
|
|
282
|
+
// 1. Direct HTTP status codes
|
|
283
|
+
if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
|
|
284
|
+
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
|
|
285
|
+
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
|
286
|
+
|
|
287
|
+
// 2. Standard gRPC / cloud error codes
|
|
288
|
+
if (code === 'RESOURCE_EXHAUSTED' && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT'))) return true;
|
|
289
|
+
if ((code === 'UNAVAILABLE' || code === 'INTERNAL') && switchCodes.has('SERVER')) return true;
|
|
290
|
+
if (code === 'DEADLINE_EXCEEDED' && switchCodes.has('TIMEOUT')) return true;
|
|
291
|
+
if ((code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED') && switchCodes.has('AUTH')) return true;
|
|
292
|
+
|
|
293
|
+
// 3. Named switch code matching
|
|
294
|
+
if (code && switchCodes.has(code)) return true;
|
|
295
|
+
|
|
296
|
+
// 4. Fallback text pattern matching
|
|
297
|
+
return SWITCHABLE_MESSAGE_PATTERN.test(message);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Format an informative user-facing exhaustion message with recovery countdown.
|
|
302
|
+
*/
|
|
303
|
+
export function formatExhaustionMessage(provider, pool, now = Date.now()) {
|
|
304
|
+
const list = pool.refs ?? [];
|
|
305
|
+
const total = list.length;
|
|
306
|
+
let minWaitMs = Infinity;
|
|
307
|
+
if (pool.state && pool.state.failedUntil) {
|
|
308
|
+
for (const ref of list) {
|
|
309
|
+
const until = pool.state.failedUntil.get(ref) ?? 0;
|
|
310
|
+
if (until > now) {
|
|
311
|
+
const wait = until - now;
|
|
312
|
+
if (wait < minWaitMs) minWaitMs = wait;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const sec = Number.isFinite(minWaitMs) && minWaitMs > 0 ? Math.ceil(minWaitMs / 1000) : 60;
|
|
317
|
+
return `[dsh-key-rotation] All ${total} keys for provider '${provider}' are temporarily exhausted. Next key recovers in ~${sec}s.`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
323
|
+
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
324
|
+
*/
|
|
325
|
+
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
326
|
+
if (!pool || !pool.expiresAt) return [];
|
|
327
|
+
const DAY_MS = 86400000;
|
|
328
|
+
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
329
|
+
return Object.entries(pool.expiresAt)
|
|
330
|
+
.filter(([, at]) => at > now && at <= horizon)
|
|
331
|
+
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
332
|
+
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Dedupe: true when a day-level notification for key is due. */
|
|
336
|
+
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
337
|
+
const DAY_MS = 86400000;
|
|
338
|
+
if (!lastNotified.has(key)) {
|
|
339
|
+
lastNotified.set(key, now);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const last = lastNotified.get(key);
|
|
343
|
+
if (now - last < DAY_MS) return false;
|
|
344
|
+
lastNotified.set(key, now);
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
349
|
+
export function costForDay(costDays, day) {
|
|
350
|
+
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
351
|
+
let total = 0;
|
|
352
|
+
for (const perRef of (costDays?.values() ?? [])) {
|
|
353
|
+
total += perRef.get(d) ?? 0;
|
|
354
|
+
}
|
|
355
|
+
return total;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Total spend over the last 7 ISO days ending today. */
|
|
359
|
+
export function costForWeek(costDays, now = Date.now()) {
|
|
360
|
+
let total = 0;
|
|
361
|
+
for (let i = 0; i < 7; i++) {
|
|
362
|
+
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
363
|
+
total += costForDay(costDays, d);
|
|
364
|
+
}
|
|
365
|
+
return total;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
369
|
+
export function budgetVerdict(spend, budget) {
|
|
370
|
+
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
371
|
+
const ratio = spend / budget;
|
|
372
|
+
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
373
|
+
}
|
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.37",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
package/lib/agent-budget.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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/canary.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
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/incident.js
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
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/maintenance.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// lib/maintenance.js - pure helpers for #207 (expiry pre-warning) and
|
|
2
|
-
// #208 (cost budget). No I/O; the timers in index.js call these and decide
|
|
3
|
-
// whether to send webhooks.
|
|
4
|
-
|
|
5
|
-
const DAY_MS = 86400000;
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* #207: keys of `pool` whose expiresAt falls within the next `warnDays`.
|
|
9
|
-
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
10
|
-
*/
|
|
11
|
-
export function expiringSoon(pool, warnDays, now = Date.now()) {
|
|
12
|
-
if (!pool || !pool.expiresAt) return [];
|
|
13
|
-
const horizon = now + Math.max(1, warnDays ?? 7) * DAY_MS;
|
|
14
|
-
return Object.entries(pool.expiresAt)
|
|
15
|
-
.filter(([, at]) => at > now && at <= horizon)
|
|
16
|
-
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
17
|
-
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** #207 dedupe: true when a day-level notification for `key` is due. */
|
|
21
|
-
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
22
|
-
if (!lastNotified.has(key)) {
|
|
23
|
-
lastNotified.set(key, now);
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
const last = lastNotified.get(key);
|
|
27
|
-
if (now - last < DAY_MS) return false;
|
|
28
|
-
lastNotified.set(key, now);
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* #208: total spend of a pool on ISO day `day` (defaults to today)
|
|
34
|
-
* across costDays Map<ref, Map<day, cost>>.
|
|
35
|
-
*/
|
|
36
|
-
export function costForDay(costDays, day) {
|
|
37
|
-
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
38
|
-
let total = 0;
|
|
39
|
-
for (const perRef of (costDays?.values() ?? [])) {
|
|
40
|
-
total += perRef.get(d) ?? 0;
|
|
41
|
-
}
|
|
42
|
-
return total;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** #208: total spend over the last 7 ISO days ending today. */
|
|
46
|
-
export function costForWeek(costDays, now = Date.now()) {
|
|
47
|
-
let total = 0;
|
|
48
|
-
for (let i = 0; i < 7; i++) {
|
|
49
|
-
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
50
|
-
total += costForDay(costDays, d);
|
|
51
|
-
}
|
|
52
|
-
return total;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* #208 budget verdict for a pool: { spend, budget, ratio, warn, exceeded }.
|
|
57
|
-
* budget <= 0 -> never warn.
|
|
58
|
-
*/
|
|
59
|
-
export function budgetVerdict(spend, budget) {
|
|
60
|
-
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
61
|
-
const ratio = spend / budget;
|
|
62
|
-
// warn from 80%, exceeded at 100%
|
|
63
|
-
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
64
|
-
}
|
package/lib/region.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
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/shadow.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
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
|
-
}
|