@goodandready/dsh-key-rotation 0.7.10 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +182 -33
- package/lib/pool.js +7 -0
- package/package.json +1 -4
package/lib/index.js
CHANGED
|
@@ -46,6 +46,8 @@ const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
|
46
46
|
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
47
47
|
const KEY_PATH = '/dsh-key-rotation/key';
|
|
48
48
|
const RESET_PATH = '/dsh-key-rotation/reset';
|
|
49
|
+
const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
50
|
+
const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
49
51
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
50
52
|
|
|
51
53
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
@@ -81,10 +83,17 @@ export const Config = Schema.object({
|
|
|
81
83
|
maxCooldownMs: Schema.number(),
|
|
82
84
|
notifyWebhook: Schema.string().default(''),
|
|
83
85
|
notifyThreshold: Schema.number().default(3),
|
|
86
|
+
backupDir: Schema.string().default(''),
|
|
87
|
+
backupIntervalMs: Schema.number().default(86400000),
|
|
88
|
+
backupKeep: Schema.number().default(7),
|
|
84
89
|
providers: Schema.array(Schema.object({
|
|
85
90
|
provider: Schema.string().required(),
|
|
86
91
|
keys: Schema.array(Schema.string()).default([]),
|
|
87
92
|
weights: Schema.array(Schema.number()).default([]),
|
|
93
|
+
models: Schema.dict(Schema.object({
|
|
94
|
+
keys: Schema.array(Schema.string()).default([]),
|
|
95
|
+
weights: Schema.array(Schema.number()).default([]),
|
|
96
|
+
})).default({}),
|
|
88
97
|
cooldownMs: Schema.number(),
|
|
89
98
|
maxCooldownMs: Schema.number(),
|
|
90
99
|
})).default([...DEFAULT_PROVIDERS]),
|
|
@@ -247,6 +256,68 @@ export function apply(ctx, config = {}) {
|
|
|
247
256
|
// ── key-pool state, persisted across config reloads ──
|
|
248
257
|
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
249
258
|
const poolState = new Map();
|
|
259
|
+
// Periodic backup of pools config
|
|
260
|
+
ctx.effect(() => {
|
|
261
|
+
const { backupDir, backupIntervalMs, backupKeep } = buildRuntime();
|
|
262
|
+
if (!backupDir) return;
|
|
263
|
+
const id = setInterval(() => {
|
|
264
|
+
try {
|
|
265
|
+
const fs = require('node:fs');
|
|
266
|
+
const path = require('node:path');
|
|
267
|
+
const dir = backupDir;
|
|
268
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
269
|
+
const now = new Date();
|
|
270
|
+
const dateStr = now.toISOString().slice(0,10).replace(/-/g,'');
|
|
271
|
+
const file = path.join(dir, 'pools-' + dateStr + '.json');
|
|
272
|
+
const data = JSON.stringify({ backup: now.toISOString(), providers: getConfig()?.providers ?? [] }, null, 2);
|
|
273
|
+
fs.writeFileSync(file, data, 'utf8');
|
|
274
|
+
// prune old backups
|
|
275
|
+
const keep = backupKeep || 7;
|
|
276
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith('pools-') && f.endsWith('.json')).sort();
|
|
277
|
+
while (files.length > keep) {
|
|
278
|
+
const old = files.shift();
|
|
279
|
+
fs.unlinkSync(path.join(dir, old));
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
console.warn('[dsh-key-rotation] backup failed:', String(e?.message ?? e));
|
|
283
|
+
}
|
|
284
|
+
}, backupIntervalMs || 86400000);
|
|
285
|
+
return () => clearInterval(id);
|
|
286
|
+
}, 'dsh-key-rotation: backup pools');
|
|
287
|
+
// Periodic save of usage/cost stats to file
|
|
288
|
+
ctx.effect(() => {
|
|
289
|
+
const { backupDir } = buildRuntime();
|
|
290
|
+
if (!backupDir) return;
|
|
291
|
+
try {
|
|
292
|
+
const fs = require('node:fs');
|
|
293
|
+
const path = require('node:path');
|
|
294
|
+
const statsFile = path.join(backupDir, 'stats.json');
|
|
295
|
+
// Load existing stats at startup
|
|
296
|
+
try {
|
|
297
|
+
if (fs.existsSync(statsFile)) {
|
|
298
|
+
const saved = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
299
|
+
for (const st of poolState.values()) {
|
|
300
|
+
if (saved.usageCounts && st.usageCounts) { for (const [k, v] of Object.entries(saved.usageCounts)) st.usageCounts.set(k, (st.usageCounts.get(k) ?? 0) + v); }
|
|
301
|
+
if (saved.costPerKey && st.costPerKey) { for (const [k, v] of Object.entries(saved.costPerKey)) st.costPerKey.set(k, (st.costPerKey.get(k) ?? 0) + v); }
|
|
302
|
+
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); } }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch {}
|
|
306
|
+
// Periodic save
|
|
307
|
+
const id = setInterval(() => {
|
|
308
|
+
try {
|
|
309
|
+
const usageCounts = {}; const costPerKey = {}; const lastUsedAt = {};
|
|
310
|
+
for (const [base, st] of poolState) {
|
|
311
|
+
if (st.usageCounts) for (const [k, v] of st.usageCounts) usageCounts[k] = v;
|
|
312
|
+
if (st.costPerKey) for (const [k, v] of st.costPerKey) costPerKey[k] = v;
|
|
313
|
+
if (st.lastUsedAt) for (const [k, v] of st.lastUsedAt) lastUsedAt[k] = v;
|
|
314
|
+
}
|
|
315
|
+
fs.writeFileSync(statsFile, JSON.stringify({ t: Date.now(), usageCounts, costPerKey, lastUsedAt }), 'utf8');
|
|
316
|
+
} catch {}
|
|
317
|
+
}, 60000);
|
|
318
|
+
return () => clearInterval(id);
|
|
319
|
+
} catch { return () => {}; }
|
|
320
|
+
}, 'dsh-key-rotation: persist stats');
|
|
250
321
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
251
322
|
ctx.effect(() => {
|
|
252
323
|
const id = setInterval(() => {
|
|
@@ -283,42 +354,55 @@ export function apply(ctx, config = {}) {
|
|
|
283
354
|
const poolByRef = new Map();
|
|
284
355
|
// provider route (from llm-pi-ai profiles) -> its key pool
|
|
285
356
|
const providerToPool = new Map();
|
|
357
|
+
// per-model key pools: provider -> Map<model, pool>
|
|
358
|
+
const modelPoolByProvider = new Map();
|
|
286
359
|
// clone route ids (for the settings dropdown filter)
|
|
287
360
|
const cloneIds = new Set();
|
|
288
361
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
failCounts: new Map(),
|
|
297
|
-
pointer: 0,
|
|
298
|
-
lastUsed: undefined,
|
|
299
|
-
// Счётчики для карточки настроек: без них о работе ротации можно было
|
|
300
|
-
// судить только по console.warn на сервере.
|
|
301
|
-
switches: 0,
|
|
302
|
-
lastReason: undefined,
|
|
303
|
-
lastSwitchAt: undefined,
|
|
304
|
-
lastExhaustionAt: undefined,
|
|
305
|
-
exhaustionCount: 0,
|
|
306
|
-
events: [],
|
|
307
|
-
usageCounts: new Map(),
|
|
362
|
+
const makeState = (base) => {
|
|
363
|
+
let st = poolState.get(base);
|
|
364
|
+
if (!st) {
|
|
365
|
+
st = {
|
|
366
|
+
failedUntil: new Map(), failCounts: new Map(), pointer: 0, lastUsed: undefined,
|
|
367
|
+
switches: 0, lastReason: undefined, lastSwitchAt: undefined,
|
|
368
|
+
lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(),
|
|
308
369
|
};
|
|
309
|
-
poolState.set(
|
|
370
|
+
poolState.set(base, st);
|
|
310
371
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
372
|
+
return st;
|
|
373
|
+
};
|
|
374
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax) => {
|
|
375
|
+
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
376
|
+
if (refs.length === 0) return null;
|
|
377
|
+
const w = Array.isArray(weights) ? weights : [];
|
|
314
378
|
const weightedRefs = [];
|
|
315
379
|
for (let i = 0; i < refs.length; i++) {
|
|
316
|
-
const
|
|
317
|
-
for (let k = 0; k <
|
|
380
|
+
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
381
|
+
for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
|
|
382
|
+
}
|
|
383
|
+
return { base, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
384
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax };
|
|
385
|
+
};
|
|
386
|
+
for (const p of cfg.providers ?? []) {
|
|
387
|
+
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
388
|
+
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
389
|
+
// base provider pool (fallback)
|
|
390
|
+
const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax);
|
|
391
|
+
if (pool) {
|
|
392
|
+
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
393
|
+
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
394
|
+
}
|
|
395
|
+
// per-model pools
|
|
396
|
+
const models = p.models ?? {};
|
|
397
|
+
const byModel = new Map();
|
|
398
|
+
for (const [model, mp] of Object.entries(models)) {
|
|
399
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax);
|
|
400
|
+
if (mpool) {
|
|
401
|
+
byModel.set(model, mpool);
|
|
402
|
+
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
403
|
+
}
|
|
318
404
|
}
|
|
319
|
-
|
|
320
|
-
for (const ref of refs) poolByRef.set(ref, pool);
|
|
321
|
-
for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
405
|
+
if (byModel.size > 0) modelPoolByProvider.set(p.provider, byModel);
|
|
322
406
|
}
|
|
323
407
|
|
|
324
408
|
let profiles = {};
|
|
@@ -337,7 +421,7 @@ export function apply(ctx, config = {}) {
|
|
|
337
421
|
for (const key of [...poolState.keys()]) {
|
|
338
422
|
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
339
423
|
}
|
|
340
|
-
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, poolByRef, providerToPool, cloneIds };
|
|
424
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
341
425
|
}
|
|
342
426
|
|
|
343
427
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -704,6 +788,68 @@ export function apply(ctx, config = {}) {
|
|
|
704
788
|
},
|
|
705
789
|
}), 'dsh-key-rotation: reset route');
|
|
706
790
|
|
|
791
|
+
ctx.effect(() => ctx.webServer.register({
|
|
792
|
+
kind: 'exact',
|
|
793
|
+
path: IMPORT_PATH,
|
|
794
|
+
handler: async (req, res) => {
|
|
795
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
796
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
|
|
797
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
798
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
799
|
+
if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
|
|
800
|
+
try {
|
|
801
|
+
const resp = await fetch(url);
|
|
802
|
+
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
803
|
+
const data = await resp.json();
|
|
804
|
+
if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
|
|
805
|
+
const settings = ctx.get('settings');
|
|
806
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
807
|
+
const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
|
|
808
|
+
const cur = desc?.value?.providers ?? [];
|
|
809
|
+
const merged = new Map();
|
|
810
|
+
for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
|
|
811
|
+
for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
|
|
812
|
+
const mergedArr = [...merged.values()];
|
|
813
|
+
await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
|
|
814
|
+
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
815
|
+
} catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
|
|
816
|
+
},
|
|
817
|
+
}), 'dsh-key-rotation: import route');
|
|
818
|
+
|
|
819
|
+
// Health for external panels (Beszel/Uptime)
|
|
820
|
+
ctx.effect(() => ctx.webServer.register({
|
|
821
|
+
kind: 'exact',
|
|
822
|
+
path: HEALTH_PATH,
|
|
823
|
+
handler: async (req, res) => {
|
|
824
|
+
if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
|
|
825
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
826
|
+
// also allow plain loopback without Origin
|
|
827
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
|
|
828
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
|
|
829
|
+
}
|
|
830
|
+
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
831
|
+
const now = Date.now();
|
|
832
|
+
const pools = {};
|
|
833
|
+
let exhaustedAny = false;
|
|
834
|
+
const { poolByRef: pr } = buildRuntime();
|
|
835
|
+
const seenH = new Set();
|
|
836
|
+
for (const pool of pr.values()) {
|
|
837
|
+
if (seenH.has(pool.base)) continue;
|
|
838
|
+
seenH.add(pool.base);
|
|
839
|
+
let healthy = 0;
|
|
840
|
+
for (const ref of pool.refs) {
|
|
841
|
+
const until = pool.state.failedUntil.get(ref);
|
|
842
|
+
if (!(until !== undefined && until > now)) healthy++;
|
|
843
|
+
}
|
|
844
|
+
const total = pool.refs.length;
|
|
845
|
+
const exhausted = healthy === 0 && total > 0;
|
|
846
|
+
if (exhausted) exhaustedAny = true;
|
|
847
|
+
pools[pool.base] = { healthy, total, exhausted };
|
|
848
|
+
}
|
|
849
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
|
|
850
|
+
},
|
|
851
|
+
}), 'dsh-key-rotation: health');
|
|
852
|
+
|
|
707
853
|
// ── test route: dry-run a single key without rotation ──
|
|
708
854
|
ctx.effect(() => ctx.webServer.register({
|
|
709
855
|
kind: 'exact',
|
|
@@ -738,8 +884,9 @@ export function apply(ctx, config = {}) {
|
|
|
738
884
|
// straight through.
|
|
739
885
|
ctx.on('llm/stream', (options, next) => {
|
|
740
886
|
if (options[MARKER]) return next();
|
|
741
|
-
const { providerToPool } = buildRuntime();
|
|
742
|
-
const
|
|
887
|
+
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
|
888
|
+
const byModel = modelPoolByProvider.get(options.provider);
|
|
889
|
+
const pool = (byModel && byModel.get(options.model)) || providerToPool.get(options.provider);
|
|
743
890
|
if (!pool) return next();
|
|
744
891
|
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
|
|
745
892
|
return rotate(options, pool);
|
|
@@ -752,8 +899,10 @@ export function apply(ctx, config = {}) {
|
|
|
752
899
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
753
900
|
const provider = payload?.provider ?? payload?.failure?.provider ?? '';
|
|
754
901
|
if (!provider) return next();
|
|
755
|
-
const { providerToPool, switchCodes } = buildRuntime();
|
|
756
|
-
const
|
|
902
|
+
const { providerToPool, modelPoolByProvider, switchCodes } = buildRuntime();
|
|
903
|
+
const model = payload?.model || payload?.failure?.model || '';
|
|
904
|
+
const byModel = modelPoolByProvider.get(provider);
|
|
905
|
+
const pool = (byModel && byModel.get(model)) || providerToPool.get(provider);
|
|
757
906
|
if (!pool) return next();
|
|
758
907
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
759
908
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
package/lib/pool.js
CHANGED
|
@@ -176,3 +176,10 @@ export function parseRetryAfter(value) {
|
|
|
176
176
|
}
|
|
177
177
|
return undefined;
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/** Pick a key pool for a (provider, model) pair. Model sub-pools win over the
|
|
181
|
+
* provider base pool; falls back to the base pool when no sub-pool matches. */
|
|
182
|
+
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
183
|
+
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
184
|
+
return (byModel && byModel.get(model)) || (providerToPool && providerToPool.get(provider)) || null;
|
|
185
|
+
}
|
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.11",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -54,8 +54,5 @@
|
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"test": "node --test test/*.test.js"
|
|
57
|
-
},
|
|
58
|
-
"publishConfig": {
|
|
59
|
-
"access": "public"
|
|
60
57
|
}
|
|
61
58
|
}
|