@goodandready/dsh-key-rotation 0.7.35 → 0.7.36
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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/client.js +27 -6
- package/lib/index.js +33 -277
- package/lib/pool.js +110 -1
- package/package.json +2 -3
- 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/README.md
CHANGED
|
@@ -253,6 +253,14 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
|
|
|
253
253
|
|
|
254
254
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
255
255
|
|
|
256
|
+
### v0.7.36
|
|
257
|
+
- **Architecture de-bloat & hardening**: Removed 6 unused/overengineered modules (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) and dead route registrations.
|
|
258
|
+
- **High-throughput buildRuntime memoization**: Eliminates per-token deep-cloning and schema validation on every streaming chunk.
|
|
259
|
+
- **Atomic round-robin pointer rotation**: Concurrent requests advance pointer immediately on candidate selection, eliminating race conditions on simultaneous tool calls.
|
|
260
|
+
- **Enhanced switchable error detection**: Direct parsing of HTTP status codes (`429`, `401`, `403`, `5xx`) and gRPC codes (`RESOURCE_EXHAUSTED`, `UNAVAILABLE`) alongside regex fallback.
|
|
261
|
+
- **Informative user exhaustion messaging**: Clear countdown notice with next key recovery ETA when all keys in a pool are cooling down.
|
|
262
|
+
- **Smart polling**: Client background polling paused when browser tab is inactive (`document.visibilityState`).
|
|
263
|
+
|
|
256
264
|
### v0.7.35
|
|
257
265
|
- **Lifecycle Cleanups**: Wrapped `credentials.resolve` patch and `ctx.on` event handlers (`llm/stream`, `agent/request-error`) in `ctx.effect` scopes with guaranteed unmount cleanup (#238, #239).
|
|
258
266
|
- **Settings & Secret Roles**: Added `.role('secret')` to `incidentGitHubToken` and `webhookActionToken` in `Config` schema for automatic UI masking (#237).
|
package/README.ru.md
CHANGED
|
@@ -253,6 +253,14 @@ dsh-key-rotation:
|
|
|
253
253
|
|
|
254
254
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
255
255
|
|
|
256
|
+
### v0.7.36
|
|
257
|
+
- **Глубокий рефакторинг и деблоатинг**: Удалены 6 неиспользуемых модулей оверинжиниринга (`shadow`, `incident`, `agent-budget`, `region`, `canary`, `maintenance`) и сопутствующие устаревшие эндпоинты.
|
|
258
|
+
- **Мемоизация buildRuntime**: Устранено глубокое клонирование и повторный парсинг схемы на каждом токене/чанге стрима.
|
|
259
|
+
- **Атомарная ротация указателя**: Устранено состояние гонки при параллельных вызовах инструментов — указатель смещается сразу при выборе кандидата.
|
|
260
|
+
- **Точная детекция ошибок**: Прямая проверка кодов HTTP (`429`, `401`, `403`, `5xx`) и gRPC (`RESOURCE_EXHAUSTED`, `UNAVAILABLE`) с фоллбеком на регулярные выражения.
|
|
261
|
+
- **Понятные сообщения при исчерпании пула**: Информативное уведомление с таймером восстановления ближайшего ключа.
|
|
262
|
+
- **Энергоэффективный опрос (Smart Polling)**: Автоматическая приостановка фонового опроса при неактивной вкладке браузера (`document.visibilityState`).
|
|
263
|
+
|
|
256
264
|
### v0.7.35
|
|
257
265
|
- **Очистка жизненного цикла**: Патч `credentials.resolve` и слушатели событий `ctx.on` (`llm/stream`, `agent/request-error`) переведены в скоупы `ctx.effect` с автоматическим восстановлением функций и отпиской при выгрузке плагина (#238, #239).
|
|
258
266
|
- **Роли секретов в схеме**: Полям `incidentGitHubToken` и `webhookActionToken` в схеме `Config` присвоена роль `.role('secret')` для маскирования в UI (#237).
|
package/README.zh.md
CHANGED
|
@@ -214,6 +214,14 @@ dsh-key-rotation:
|
|
|
214
214
|
|
|
215
215
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
216
216
|
|
|
217
|
+
### v0.7.36
|
|
218
|
+
- **架构精简与稳定性加固**:移除 6 个过度设计的模块(`shadow`、`incident`、`agent-budget`、`region`、`canary`、`maintenance`)与废弃端点。
|
|
219
|
+
- **buildRuntime 高性能记忆化**:消除每个流式 token/chunk 上的深拷贝与模式重解析开销。
|
|
220
|
+
- **原子轮询指针推进**:并发请求在选定候选密钥时立即推进指针,消除并发工具调用中的竞争条件。
|
|
221
|
+
- **增强的可切换错误检测**:直接解析 HTTP 状态码(`429`, `401`, `403`, `5xx`)与 gRPC 状态码(`RESOURCE_EXHAUSTED`, `UNAVAILABLE`)。
|
|
222
|
+
- **用户友好的耗尽提示**:密钥池耗尽时返回带恢复倒计时的清晰通知。
|
|
223
|
+
- **智能轮询(Smart Polling)**:标签页不活动时暂停客户端后台轮询。
|
|
224
|
+
|
|
217
225
|
### v0.7.35
|
|
218
226
|
- **生命周期清理**: 将 `credentials.resolve` 猴子补丁和 `ctx.on` 事件监听器 (`llm/stream`, `agent/request-error`) 封装在 `ctx.effect` 作用域内,确保卸载时自动注销并恢复原始方法 (#238, #239)。
|
|
219
227
|
- **配置密钥角色**: 在 `Config` Schema 中为 `incidentGitHubToken` 和 `webhookActionToken` 增加 `.role('secret')`,避免明文泄露并在 UI 中掩码显示 (#237)。
|
package/lib/client.js
CHANGED
|
@@ -240,12 +240,13 @@ window.__ModuleLoader__.load({
|
|
|
240
240
|
// отдельными отмеченными галочками, чтобы правило нельзя было потерять.
|
|
241
241
|
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
242
242
|
|
|
243
|
-
/** Опрос статуса ротации, пока раздел настроек
|
|
243
|
+
/** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
|
|
244
244
|
function useRotationStatus() {
|
|
245
245
|
const [byProvider, setByProvider] = React.useState({});
|
|
246
246
|
React.useEffect(() => {
|
|
247
247
|
let alive = true;
|
|
248
248
|
const pull = () => {
|
|
249
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
249
250
|
fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
|
|
250
251
|
.then((r) => (r.ok ? r.json() : null))
|
|
251
252
|
.then((data) => {
|
|
@@ -258,17 +259,24 @@ window.__ModuleLoader__.load({
|
|
|
258
259
|
};
|
|
259
260
|
pull();
|
|
260
261
|
const id = setInterval(pull, 4000);
|
|
261
|
-
|
|
262
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
|
|
263
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
264
|
+
return () => {
|
|
265
|
+
alive = false;
|
|
266
|
+
clearInterval(id);
|
|
267
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
268
|
+
};
|
|
262
269
|
}, []);
|
|
263
270
|
return byProvider;
|
|
264
271
|
}
|
|
265
272
|
|
|
266
|
-
/** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
|
|
273
|
+
/** Последний probe-результат по каждому ключу (#219): /sandbox-cache (smart polling). */
|
|
267
274
|
function useProbeCache() {
|
|
268
275
|
const [cache, setCache] = React.useState({});
|
|
269
276
|
React.useEffect(() => {
|
|
270
277
|
let alive = true;
|
|
271
278
|
const pull = () => {
|
|
279
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
272
280
|
fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
|
|
273
281
|
.then((r) => (r.ok ? r.json() : null))
|
|
274
282
|
.then((data) => { if (alive && data) setCache(data); })
|
|
@@ -276,7 +284,13 @@ window.__ModuleLoader__.load({
|
|
|
276
284
|
};
|
|
277
285
|
pull();
|
|
278
286
|
const id = setInterval(pull, 4000);
|
|
279
|
-
|
|
287
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
|
|
288
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
289
|
+
return () => {
|
|
290
|
+
alive = false;
|
|
291
|
+
clearInterval(id);
|
|
292
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
293
|
+
};
|
|
280
294
|
}, []);
|
|
281
295
|
return cache;
|
|
282
296
|
}
|
|
@@ -1076,14 +1090,21 @@ window.__ModuleLoader__.load({
|
|
|
1076
1090
|
React.useEffect(() => {
|
|
1077
1091
|
let alive = true;
|
|
1078
1092
|
const load = () => {
|
|
1093
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
1079
1094
|
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
1080
1095
|
.then((r) => (r.ok ? r.json() : null))
|
|
1081
1096
|
.then((d) => { if (alive) setSnap(d); })
|
|
1082
1097
|
.catch(() => {});
|
|
1083
1098
|
};
|
|
1084
1099
|
load();
|
|
1085
|
-
const id = setInterval(load,
|
|
1086
|
-
|
|
1100
|
+
const id = setInterval(load, 5000);
|
|
1101
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
|
|
1102
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
1103
|
+
return () => {
|
|
1104
|
+
alive = false;
|
|
1105
|
+
clearInterval(id);
|
|
1106
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
1107
|
+
};
|
|
1087
1108
|
}, []);
|
|
1088
1109
|
|
|
1089
1110
|
React.useEffect(() => {
|
package/lib/index.js
CHANGED
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
35
|
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
-
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool } from './pool.js';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError, formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
37
37
|
|
|
38
38
|
export const name = 'dsh-key-rotation';
|
|
39
39
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
40
|
-
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
|
|
40
|
+
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage };
|
|
41
41
|
|
|
42
42
|
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
43
43
|
const NS = 'dsh-key-rotation';
|
|
@@ -52,27 +52,15 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
52
52
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
53
53
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
54
54
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
55
|
-
const AGENT_BUDGET_PATH = '/dsh-key-rotation/agent-budget';
|
|
56
|
-
const REGIONS_PATH = '/dsh-key-rotation/regions';
|
|
57
|
-
const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
|
|
58
|
-
const SHADOW_PATH = '/dsh-key-rotation/shadow';
|
|
59
|
-
const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
|
|
60
|
-
const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
|
|
61
55
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
62
56
|
import { healIdleCooldowns } from './heal.js';
|
|
63
57
|
import { LatencyHistogram } from './histogram.js';
|
|
64
58
|
import { pickCascadeFallback } from './cascade.js';
|
|
65
59
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
66
60
|
import { nextQuotaReset } from './quota-window.js';
|
|
67
|
-
import { CanaryProber } from './canary.js';
|
|
68
61
|
import { QuotaStore } from './quota.js';
|
|
69
|
-
import { AgentBudget } from './agent-budget.js';
|
|
70
|
-
import { RegionMap } from './region.js';
|
|
71
|
-
import { IncidentReporter } from './incident.js';
|
|
72
|
-
import { ShadowRouter } from './shadow.js';
|
|
73
62
|
import { WebhookSender } from './webhook.js';
|
|
74
63
|
import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
|
|
75
|
-
import { expiringSoon, shouldNotifyDaily, costForDay, budgetVerdict, costForWeek } from './maintenance.js';
|
|
76
64
|
import { usageRows, usageCsv } from './usage-report.js';
|
|
77
65
|
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
78
66
|
|
|
@@ -130,28 +118,12 @@ let lastTestCacheRunnerCtx = null;
|
|
|
130
118
|
const lastTestCache = new LastTestCache();
|
|
131
119
|
const latencyHistogram = new LatencyHistogram();
|
|
132
120
|
const quotaStore = new QuotaStore();
|
|
133
|
-
const agentBudget = new AgentBudget();
|
|
134
|
-
const regionMap = new RegionMap();
|
|
135
121
|
// Global config accessor safe against early initialization
|
|
136
122
|
let getConfig = () => null;
|
|
137
123
|
let getRuntime = () => null;
|
|
138
|
-
|
|
139
|
-
// IncidentReporter: lazily built when Config provides incidentGitHubToken + incidentGitHubBaseUrl.
|
|
140
|
-
// ponytail: never bake the token into source; repo is hardcoded (this plugin's home repo) but token is per-deploy.
|
|
141
|
-
let incidentReporter = null;
|
|
142
|
-
function ensureIncidentReporter() {
|
|
143
|
-
if (incidentReporter) return incidentReporter;
|
|
144
|
-
const cfg = getConfig();
|
|
145
|
-
const token = cfg ? cfg.incidentGitHubToken : '';
|
|
146
|
-
const baseUrl = cfg ? cfg.incidentGitHubBaseUrl : '';
|
|
147
|
-
if (!token || !baseUrl) return null;
|
|
148
|
-
incidentReporter = new IncidentReporter({ token, baseUrl, repo: 'goodandready/dsh-key-rotation', fetchImpl: globalThis.fetch });
|
|
149
|
-
return incidentReporter;
|
|
150
|
-
}
|
|
151
|
-
const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
|
|
124
|
+
let sandboxRunner = null;
|
|
152
125
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
153
126
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
154
|
-
let canaryProber = null;
|
|
155
127
|
function ensureSandboxRunner(ctx) {
|
|
156
128
|
if (sandboxRunner) return sandboxRunner;
|
|
157
129
|
// provider id or key ref -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
@@ -211,20 +183,11 @@ export const Config = Schema.object({
|
|
|
211
183
|
maxCooldownMs: Schema.number(),
|
|
212
184
|
notifyWebhook: Schema.string().default(''),
|
|
213
185
|
notifyThreshold: Schema.number().default(3),
|
|
214
|
-
backupDir: Schema.string().default(''),
|
|
215
|
-
backupIntervalMs: Schema.number().default(86400000),
|
|
216
|
-
backupKeep: Schema.number().default(7),
|
|
217
|
-
rotationScheduleDays: Schema.number().default(0),
|
|
218
186
|
selfHealCooldown: Schema.boolean().default(true),
|
|
219
187
|
selfHealIdleMs: Schema.number().default(3600000),
|
|
220
188
|
latencyEnabled: Schema.boolean().default(true),
|
|
221
189
|
latencyWindow: Schema.number().default(200),
|
|
222
|
-
incidentGitHubToken: Schema.string().role('secret').default(''),
|
|
223
|
-
incidentGitHubBaseUrl: Schema.string().default(''),
|
|
224
|
-
incidentThreshold: Schema.number().default(5),
|
|
225
190
|
concurrencyLimit: Schema.number().default(0),
|
|
226
|
-
canaryProbingEnabled: Schema.boolean().default(false),
|
|
227
|
-
canaryIntervalMs: Schema.number().default(30000),
|
|
228
191
|
cascade: Schema.array(Schema.object({
|
|
229
192
|
provider: Schema.string().required(),
|
|
230
193
|
model: Schema.string(),
|
|
@@ -391,7 +354,6 @@ async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
391
354
|
// fields that legitimately hold tokens are masked before scanning.
|
|
392
355
|
try {
|
|
393
356
|
const masked = structuredClone(section);
|
|
394
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
395
357
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
396
358
|
// notifyWebhook legitimately carries bot tokens inside URLs
|
|
397
359
|
// (api.telegram.org/bot<token>/...) - scan it for nothing.
|
|
@@ -447,39 +409,6 @@ export function apply(ctx, config = {}) {
|
|
|
447
409
|
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
448
410
|
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
449
411
|
// fresh config on every tick. Naive but correct: 60s cadence is cheap.
|
|
450
|
-
// #196: canary probing before key release from cooldown.
|
|
451
|
-
// Every canaryIntervalMs, probe refs that are in cooldown and close to expiry.
|
|
452
|
-
// Canary prober lifecycle effect
|
|
453
|
-
ctx.effect(() => {
|
|
454
|
-
const cfg = getConfig();
|
|
455
|
-
if (!cfg || !cfg.canaryProbingEnabled) return () => {};
|
|
456
|
-
const timer = setInterval(() => {
|
|
457
|
-
try {
|
|
458
|
-
const c = getConfig();
|
|
459
|
-
if (!c || !c.canaryProbingEnabled) return;
|
|
460
|
-
const runner = ensureSandboxRunner(ctx);
|
|
461
|
-
if (!runner) return;
|
|
462
|
-
if (!canaryProber) {
|
|
463
|
-
canaryProber = new CanaryProber({ sandboxRunner: runner, intervalMs: c.canaryIntervalMs });
|
|
464
|
-
}
|
|
465
|
-
const providers = Array.isArray(c.providers) ? c.providers : [];
|
|
466
|
-
for (const p of providers) {
|
|
467
|
-
const pool = buildRuntime().providerToPool.get(p.provider);
|
|
468
|
-
if (!pool) continue;
|
|
469
|
-
for (const ref of pool.refs) {
|
|
470
|
-
const until = pool.state.failedUntil.get(ref) ?? 0;
|
|
471
|
-
const now = Date.now();
|
|
472
|
-
if (until > now && until - now < (c.canaryIntervalMs ?? 30000)) {
|
|
473
|
-
canaryProber.probe(ref, ref);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} catch (_) { /* ponytail: never crash the timer */ }
|
|
478
|
-
}, cfg.canaryIntervalMs ?? 30000);
|
|
479
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
480
|
-
return () => clearInterval(timer);
|
|
481
|
-
}, 'dsh-key-rotation: canary prober');
|
|
482
|
-
|
|
483
412
|
// Self-healing idle cooldowns lifecycle effect
|
|
484
413
|
ctx.effect(() => {
|
|
485
414
|
const cfg = getConfig();
|
|
@@ -500,96 +429,9 @@ export function apply(ctx, config = {}) {
|
|
|
500
429
|
return () => clearInterval(timer);
|
|
501
430
|
}, 'dsh-key-rotation: self-healing idle');
|
|
502
431
|
|
|
503
|
-
//
|
|
504
|
-
const DASH_HTML = '';
|
|
505
|
-
// ── key-pool state, persisted across config reloads ──
|
|
432
|
+
// ── key-pool state, persisted across config reloads ──
|
|
506
433
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
507
434
|
const poolState = new Map();
|
|
508
|
-
// Periodic backup of pools config
|
|
509
|
-
ctx.effect(() => {
|
|
510
|
-
const { backupDir, backupIntervalMs, backupKeep } = buildRuntime();
|
|
511
|
-
if (!backupDir) return;
|
|
512
|
-
const id = setInterval(() => {
|
|
513
|
-
try {
|
|
514
|
-
const fs = require('node:fs');
|
|
515
|
-
const path = require('node:path');
|
|
516
|
-
const dir = backupDir;
|
|
517
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
518
|
-
const now = new Date();
|
|
519
|
-
const dateStr = now.toISOString().slice(0,10).replace(/-/g,'');
|
|
520
|
-
const file = path.join(dir, 'pools-' + dateStr + '.json');
|
|
521
|
-
const data = JSON.stringify({ backup: now.toISOString(), providers: getConfig()?.providers ?? [] }, null, 2);
|
|
522
|
-
fs.writeFileSync(file, data, 'utf8');
|
|
523
|
-
// prune old backups
|
|
524
|
-
const keep = backupKeep || 7;
|
|
525
|
-
const files = fs.readdirSync(dir).filter((f) => f.startsWith('pools-') && f.endsWith('.json')).sort();
|
|
526
|
-
while (files.length > keep) {
|
|
527
|
-
const old = files.shift();
|
|
528
|
-
fs.unlinkSync(path.join(dir, old));
|
|
529
|
-
}
|
|
530
|
-
} catch (e) {
|
|
531
|
-
console.warn('[dsh-key-rotation] backup failed:', String(e?.message ?? e));
|
|
532
|
-
}
|
|
533
|
-
}, backupIntervalMs || 86400000);
|
|
534
|
-
return () => clearInterval(id);
|
|
535
|
-
}, 'dsh-key-rotation: backup pools');
|
|
536
|
-
// Periodic save of usage/cost stats to file
|
|
537
|
-
ctx.effect(() => {
|
|
538
|
-
const { backupDir } = buildRuntime();
|
|
539
|
-
if (!backupDir) return;
|
|
540
|
-
try {
|
|
541
|
-
const fs = require('node:fs');
|
|
542
|
-
const path = require('node:path');
|
|
543
|
-
const statsFile = path.join(backupDir, 'stats.json');
|
|
544
|
-
// Load existing stats at startup
|
|
545
|
-
try {
|
|
546
|
-
if (fs.existsSync(statsFile)) {
|
|
547
|
-
const saved = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
548
|
-
for (const st of poolState.values()) {
|
|
549
|
-
if (saved.usageCounts && st.usageCounts) { for (const [k, v] of Object.entries(saved.usageCounts)) st.usageCounts.set(k, (st.usageCounts.get(k) ?? 0) + v); }
|
|
550
|
-
if (saved.costPerKey && st.costPerKey) { for (const [k, v] of Object.entries(saved.costPerKey)) st.costPerKey.set(k, (st.costPerKey.get(k) ?? 0) + v); }
|
|
551
|
-
if (saved.lastUsedAt && st.lastUsedAt) { for (const [k, v] of Object.entries(saved.lastUsedAt)) { if (!st.lastUsedAt.has(k) || v > st.lastUsedAt.get(k)) st.lastUsedAt.set(k, v); } }
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
} catch {}
|
|
555
|
-
// Periodic save
|
|
556
|
-
const id = setInterval(() => {
|
|
557
|
-
try {
|
|
558
|
-
const usageCounts = {}; const costPerKey = {}; const lastUsedAt = {};
|
|
559
|
-
for (const [base, st] of poolState) {
|
|
560
|
-
if (st.usageCounts) for (const [k, v] of st.usageCounts) usageCounts[k] = v;
|
|
561
|
-
if (st.costPerKey) for (const [k, v] of st.costPerKey) costPerKey[k] = v;
|
|
562
|
-
if (st.lastUsedAt) for (const [k, v] of st.lastUsedAt) lastUsedAt[k] = v;
|
|
563
|
-
}
|
|
564
|
-
fs.writeFileSync(statsFile, JSON.stringify({ t: Date.now(), usageCounts, costPerKey, lastUsedAt }), 'utf8');
|
|
565
|
-
} catch {}
|
|
566
|
-
}, 60000);
|
|
567
|
-
return () => clearInterval(id);
|
|
568
|
-
} catch { return () => {}; }
|
|
569
|
-
}, 'dsh-key-rotation: persist stats');
|
|
570
|
-
// Rotation schedule: shift pointer every N days
|
|
571
|
-
ctx.effect(() => {
|
|
572
|
-
const { rotationScheduleDays } = buildRuntime();
|
|
573
|
-
if (!rotationScheduleDays || rotationScheduleDays <= 0) return;
|
|
574
|
-
const intervalMs = Math.min(rotationScheduleDays * 86400000, 2147483647);
|
|
575
|
-
const id = setInterval(() => {
|
|
576
|
-
try {
|
|
577
|
-
const rt = buildRuntime();
|
|
578
|
-
let shifted = 0;
|
|
579
|
-
for (const pool of rt.poolByRef.values()) {
|
|
580
|
-
if (pool.refs.length < 2) continue;
|
|
581
|
-
const oldPtr = pool.state.pointer ?? 0;
|
|
582
|
-
pool.state.pointer = (oldPtr + 1) % pool.refs.length;
|
|
583
|
-
shifted++;
|
|
584
|
-
console.warn(`[dsh-key-rotation] ${pool.base}: scheduled rotation -> ${pool.refs[pool.state.pointer]} (day ${rotationScheduleDays})`);
|
|
585
|
-
}
|
|
586
|
-
if (shifted) console.warn(`[dsh-key-rotation] schedule: rotated ${shifted} pools`);
|
|
587
|
-
} catch (e) {
|
|
588
|
-
console.warn('[dsh-key-rotation] schedule error:', String(e?.message ?? e));
|
|
589
|
-
}
|
|
590
|
-
}, intervalMs);
|
|
591
|
-
return () => clearInterval(id);
|
|
592
|
-
}, 'dsh-key-rotation: rotation schedule');
|
|
593
435
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
594
436
|
ctx.effect(() => {
|
|
595
437
|
const id = setInterval(() => {
|
|
@@ -719,27 +561,34 @@ export function apply(ctx, config = {}) {
|
|
|
719
561
|
}, 'dsh-key-rotation: sweep expired cooldowns');
|
|
720
562
|
|
|
721
563
|
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
564
|
+
let cachedRuntime = null;
|
|
565
|
+
let lastConfigRef = null;
|
|
566
|
+
let lastProfilesRef = null;
|
|
567
|
+
|
|
722
568
|
getRuntime = buildRuntime;
|
|
723
569
|
function buildRuntime() {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
570
|
+
const rawConfig = getConfig() ?? {};
|
|
571
|
+
let currentProfiles = null;
|
|
572
|
+
try {
|
|
573
|
+
currentProfiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
|
|
574
|
+
} catch {
|
|
575
|
+
/* settings not mounted yet — empty mapping */
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (cachedRuntime && lastConfigRef === rawConfig && lastProfilesRef === currentProfiles) {
|
|
579
|
+
return cachedRuntime;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const cfg = Config(rawConfig) ?? {};
|
|
727
583
|
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
728
584
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
729
585
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
730
586
|
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
731
587
|
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
732
|
-
const backupDir = cfg.backupDir ?? '';
|
|
733
|
-
const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
|
|
734
|
-
const backupKeep = cfg.backupKeep ?? 7;
|
|
735
|
-
const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
|
|
736
588
|
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
737
589
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
738
590
|
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
739
|
-
const incidentThreshold = cfg.incidentThreshold ?? 5;
|
|
740
591
|
const concurrencyLimit = cfg.concurrencyLimit ?? 0;
|
|
741
|
-
const canaryProbingEnabled = cfg.canaryProbingEnabled ?? false;
|
|
742
|
-
const canaryIntervalMs = cfg.canaryIntervalMs ?? 30000;
|
|
743
592
|
const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
|
|
744
593
|
const quotaResetWindow = cfg.quotaResetWindow || null;
|
|
745
594
|
|
|
@@ -856,7 +705,10 @@ export function apply(ctx, config = {}) {
|
|
|
856
705
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
857
706
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
858
707
|
}
|
|
859
|
-
|
|
708
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
709
|
+
lastConfigRef = rawConfig;
|
|
710
|
+
lastProfilesRef = currentProfiles;
|
|
711
|
+
return cachedRuntime;
|
|
860
712
|
}
|
|
861
713
|
|
|
862
714
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -904,9 +756,10 @@ export function apply(ctx, config = {}) {
|
|
|
904
756
|
continue;
|
|
905
757
|
}
|
|
906
758
|
}
|
|
759
|
+
// Advance pointer immediately so concurrent requests round-robin across distinct healthy keys
|
|
760
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
907
761
|
let hit = await original(candidate);
|
|
908
762
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
909
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
910
763
|
pool.state.lastUsed = candidate;
|
|
911
764
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
912
765
|
pool.state.failedUntil.delete(candidate);
|
|
@@ -926,7 +779,6 @@ export function apply(ctx, config = {}) {
|
|
|
926
779
|
// fallback: env var (transient, not persisted)
|
|
927
780
|
const envVal = envValue(candidate);
|
|
928
781
|
if (envVal !== undefined) {
|
|
929
|
-
pool.state.pointer = (index + 1) % list.length;
|
|
930
782
|
pool.state.lastUsed = candidate;
|
|
931
783
|
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
932
784
|
pool.state.failedUntil.delete(candidate);
|
|
@@ -1037,8 +889,7 @@ export function apply(ctx, config = {}) {
|
|
|
1037
889
|
const code = failure?.code;
|
|
1038
890
|
const message = failure?.message ?? '';
|
|
1039
891
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1040
|
-
const switchable = !yielded && kind === 'error' &&
|
|
1041
|
-
(effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
892
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
1042
893
|
if (switchable) {
|
|
1043
894
|
if (pool.state.lastUsed) {
|
|
1044
895
|
const _retry = parseRetryAfter(message);
|
|
@@ -1167,7 +1018,8 @@ export function apply(ctx, config = {}) {
|
|
|
1167
1018
|
}
|
|
1168
1019
|
}
|
|
1169
1020
|
|
|
1170
|
-
|
|
1021
|
+
const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
|
|
1022
|
+
yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
|
|
1171
1023
|
})();
|
|
1172
1024
|
}
|
|
1173
1025
|
|
|
@@ -1337,7 +1189,6 @@ export function apply(ctx, config = {}) {
|
|
|
1337
1189
|
const exportable = { ...value };
|
|
1338
1190
|
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1339
1191
|
exportable.webhookActionToken = '';
|
|
1340
|
-
if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
|
|
1341
1192
|
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1342
1193
|
return;
|
|
1343
1194
|
}
|
|
@@ -1349,7 +1200,6 @@ export function apply(ctx, config = {}) {
|
|
|
1349
1200
|
// #200 leak guard applies to imported content too
|
|
1350
1201
|
try {
|
|
1351
1202
|
const masked = structuredClone(snap);
|
|
1352
|
-
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
1353
1203
|
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1354
1204
|
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1355
1205
|
const findings = findSecrets(JSON.stringify(masked));
|
|
@@ -1363,7 +1213,6 @@ export function apply(ctx, config = {}) {
|
|
|
1363
1213
|
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1364
1214
|
const merged = { ...cur, ...snap };
|
|
1365
1215
|
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1366
|
-
if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
|
|
1367
1216
|
try {
|
|
1368
1217
|
await settings.replace(NS, merged, desc.revision);
|
|
1369
1218
|
const after = descriptorOf(ctx, NS);
|
|
@@ -1609,61 +1458,7 @@ export function apply(ctx, config = {}) {
|
|
|
1609
1458
|
},
|
|
1610
1459
|
}), 'dsh-key-rotation: sandbox cache');
|
|
1611
1460
|
|
|
1612
|
-
// Auto-incident reset (#8).
|
|
1613
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1614
|
-
kind: 'exact',
|
|
1615
|
-
path: INCIDENT_RESET_PATH,
|
|
1616
|
-
handler: (req, res) => {
|
|
1617
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1618
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1619
|
-
readJson(req).then((body) => {
|
|
1620
|
-
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1621
|
-
if (provider) incidentReporter.resetCooldown(provider);
|
|
1622
|
-
else incidentReporter.resetCooldown();
|
|
1623
|
-
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1624
|
-
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1625
|
-
},
|
|
1626
|
-
}), 'dsh-key-rotation: incident-reset');
|
|
1627
1461
|
|
|
1628
|
-
// #198: 1-click Health Matrix — parallel probe of all configured keys.
|
|
1629
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1630
|
-
kind: 'exact',
|
|
1631
|
-
path: TEST_MATRIX_PATH,
|
|
1632
|
-
handler: async (req, res) => {
|
|
1633
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1634
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: matrix is local-only' } }); return; }
|
|
1635
|
-
const cfg = getConfig();
|
|
1636
|
-
const runner = ensureSandboxRunner();
|
|
1637
|
-
if (!runner) { json(res, 500, { error: { code: 'no-runner', message: 'sandbox runner unavailable' } }); return; }
|
|
1638
|
-
const providers = Array.isArray(cfg?.providers) ? cfg.providers : [];
|
|
1639
|
-
const jobs = [];
|
|
1640
|
-
for (const p of providers) {
|
|
1641
|
-
for (const ref of (p.keys ?? [])) {
|
|
1642
|
-
if (typeof ref !== 'string' || !ref) continue;
|
|
1643
|
-
jobs.push((async () => {
|
|
1644
|
-
try {
|
|
1645
|
-
const probeResult = await runner.probeModels(ref, ref);
|
|
1646
|
-
return { provider: p.provider, ref, ok: probeResult.ok, code: probeResult.code, latencyMs: probeResult.latencyMs, modelsCount: probeResult.modelsCount ?? 0 };
|
|
1647
|
-
} catch (e) {
|
|
1648
|
-
return { provider: p.provider, ref, ok: false, code: 'error', latencyMs: 0, modelsCount: 0 };
|
|
1649
|
-
}
|
|
1650
|
-
})());
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
const results = await Promise.all(jobs);
|
|
1654
|
-
json(res, 200, { at: Date.now(), total: results.length, ok: results.filter(r => r.ok).length, results });
|
|
1655
|
-
},
|
|
1656
|
-
}), 'dsh-key-rotation: test-matrix');
|
|
1657
|
-
|
|
1658
|
-
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1659
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1660
|
-
kind: 'exact',
|
|
1661
|
-
path: WEBHOOK_TEST_PATH,
|
|
1662
|
-
handler: (req, res) => {
|
|
1663
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1664
|
-
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1665
|
-
},
|
|
1666
|
-
}), 'dsh-key-rotation: webhook-test');
|
|
1667
1462
|
|
|
1668
1463
|
// #199 webhook-action: interactive webhook buttons call back here.
|
|
1669
1464
|
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
@@ -1752,39 +1547,7 @@ export function apply(ctx, config = {}) {
|
|
|
1752
1547
|
},
|
|
1753
1548
|
}), 'dsh-key-rotation: webhook-action');
|
|
1754
1549
|
|
|
1755
|
-
// Shadow A/B sampling snapshot (#9).
|
|
1756
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1757
|
-
kind: 'exact',
|
|
1758
|
-
path: SHADOW_PATH,
|
|
1759
|
-
handler: (req, res) => {
|
|
1760
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1761
|
-
json(res, 200, shadowRouter.snapshot());
|
|
1762
|
-
},
|
|
1763
|
-
}), 'dsh-key-rotation: shadow');
|
|
1764
1550
|
|
|
1765
|
-
// Region tags + failover chain (#4).
|
|
1766
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1767
|
-
kind: 'exact',
|
|
1768
|
-
path: REGIONS_PATH,
|
|
1769
|
-
handler: (req, res) => {
|
|
1770
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1771
|
-
const body = regionMap.snapshot();
|
|
1772
|
-
// Add pickFallback hints per provider for inspection.
|
|
1773
|
-
const out = {};
|
|
1774
|
-
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1775
|
-
json(res, 200, out);
|
|
1776
|
-
},
|
|
1777
|
-
}), 'dsh-key-rotation: regions');
|
|
1778
|
-
|
|
1779
|
-
// Per-agent rate budget snapshot (#3).
|
|
1780
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1781
|
-
kind: 'exact',
|
|
1782
|
-
path: AGENT_BUDGET_PATH,
|
|
1783
|
-
handler: (req, res) => {
|
|
1784
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1785
|
-
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1786
|
-
},
|
|
1787
|
-
}), 'dsh-key-rotation: agent-budget');
|
|
1788
1551
|
|
|
1789
1552
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
1790
1553
|
if (options[MARKER]) return next();
|
|
@@ -1812,7 +1575,7 @@ export function apply(ctx, config = {}) {
|
|
|
1812
1575
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
1813
1576
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
1814
1577
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1815
|
-
const switchable =
|
|
1578
|
+
const switchable = isSwitchableError(payload, effectiveSwitchCodes);
|
|
1816
1579
|
if (!switchable) return next();
|
|
1817
1580
|
const ref = pool.state.lastUsed;
|
|
1818
1581
|
if (ref) {
|
|
@@ -1835,18 +1598,15 @@ export function apply(ctx, config = {}) {
|
|
|
1835
1598
|
});
|
|
1836
1599
|
}
|
|
1837
1600
|
|
|
1838
|
-
// Notify on exhaustion: webhook
|
|
1601
|
+
// Notify on exhaustion: webhook notification.
|
|
1839
1602
|
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
1840
1603
|
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
1841
|
-
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
1604
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender }) {
|
|
1842
1605
|
if (!runtime || !pool) return;
|
|
1843
1606
|
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
1844
1607
|
if (count <= 0) return;
|
|
1845
1608
|
try {
|
|
1846
1609
|
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
1847
|
-
// #199: interactive payload when an action token is configured - the
|
|
1848
|
-
// platform formatter (webhook.js) turns `actions` into buttons whose
|
|
1849
|
-
// callback carries the token back to /dsh-key-rotation/webhook-action.
|
|
1850
1610
|
const token = runtime.webhookActionToken ?? '';
|
|
1851
1611
|
const payload = {
|
|
1852
1612
|
title: `Key pool exhausted: ${options.provider}`,
|
|
@@ -1863,10 +1623,6 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
|
1863
1623
|
};
|
|
1864
1624
|
hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
1865
1625
|
}
|
|
1866
|
-
if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
|
|
1867
|
-
const reporter = hooks.ensureIncidentReporter();
|
|
1868
|
-
if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
|
|
1869
|
-
}
|
|
1870
1626
|
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1871
1627
|
}
|
|
1872
1628
|
|
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,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"packageManager": "pnpm@10.33.2",
|
|
3
|
+
"version": "0.7.36",
|
|
5
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.",
|
|
6
5
|
"keywords": [
|
|
7
6
|
"deepseek-harness",
|
|
@@ -56,4 +55,4 @@
|
|
|
56
55
|
"scripts": {
|
|
57
56
|
"test": "node --test test/*.test.js test/*.test.mjs"
|
|
58
57
|
}
|
|
59
|
-
}
|
|
58
|
+
}
|
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
|
-
}
|