@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.
Files changed (3) hide show
  1. package/lib/index.js +182 -33
  2. package/lib/pool.js +7 -0
  3. 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
- for (const p of cfg.providers ?? []) {
290
- const refs = (p.keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
291
- if (refs.length === 0) continue;
292
- let state = poolState.get(p.provider);
293
- if (!state) {
294
- state = {
295
- failedUntil: new Map(),
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(p.provider, state);
370
+ poolState.set(base, st);
310
371
  }
311
- const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
312
- const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
313
- const weights = Array.isArray(p.weights) ? p.weights : [];
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 w = typeof weights[i] === 'number' && weights[i] > 0 ? Math.floor(weights[i]) : 1;
317
- for (let k = 0; k < w; k++) weightedRefs.push(refs[i]);
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
- const pool = { base: p.provider, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs, state, cooldownMs: poolCooldown, maxCooldownMs: poolMax };
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 pool = providerToPool.get(options.provider);
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 pool = providerToPool.get(provider);
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.10",
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
  }