@goodandready/dsh-key-rotation 0.7.13 → 0.7.15

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/client.js CHANGED
@@ -570,7 +570,20 @@ window.__ModuleLoader__.load({
570
570
  }));
571
571
  if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
572
572
  }
573
- if (info && typeof info.usage === 'number' && info.usage > 0) meta.push(h('span', { key: 'u', className: 'krot-tail', title: 'requests through this key' }, String(info.usage)));
573
+ if (info && typeof info.usage === 'number' && info.usage > 0) {
574
+ let tip = 'requests through this key';
575
+ if (info.byModel && Object.keys(info.byModel).length > 0) {
576
+ tip = Object.entries(info.byModel).map(([m, c]) => m + ': ' + c).join('\n');
577
+ }
578
+ meta.push(h('span', { key: 'u', className: 'krot-tail', title: tip }, String(info.usage)));
579
+ if (info.usageDays && Object.keys(info.usageDays).length > 0) {
580
+ const days = Object.entries(info.usageDays);
581
+ const max = Math.max(1, ...days.map(([, c]) => c));
582
+ meta.push(h('span', { key: 'g', className: 'krot-graph', title: days.map(([d, c]) => d + ': ' + c).join('\n'), style: { display: 'inline-flex', gap: '1px', alignItems: 'flex-end', height: '12px' } },
583
+ days.slice(-14).map(([d, c]) => h('span', { key: d, style: { width: '3px', height: Math.max(2, (c / max) * 12) + 'px', background: 'var(--dsw-alias-state-success-primary)', borderRadius: '1px' } }))
584
+ ));
585
+ }
586
+ }
574
587
  if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
575
588
  if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
576
589
  const tr = testResult[key];
package/lib/index.js CHANGED
@@ -33,7 +33,7 @@
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 } 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 } from './pool.js';
37
37
 
38
38
  export const name = 'dsh-key-rotation';
39
39
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
@@ -87,6 +87,7 @@ export const Config = Schema.object({
87
87
  backupIntervalMs: Schema.number().default(86400000),
88
88
  backupKeep: Schema.number().default(7),
89
89
  rotationScheduleDays: Schema.number().default(0),
90
+ rateLimitThreshold: Schema.number().default(0.1),
90
91
  providers: Schema.array(Schema.object({
91
92
  provider: Schema.string().required(),
92
93
  keys: Schema.array(Schema.string()).default([]),
@@ -255,7 +256,38 @@ export function apply(ctx, config = {}) {
255
256
  let getConfig = () => config;
256
257
  registerConfigBridge(ctx, () => buildRuntime().cloneIds);
257
258
 
258
- // ── key-pool state, persisted across config reloads ──
259
+ // Dashboard widget (#117): fixed bottom-right panel that polls /health.
260
+ const DASH_HTML = '<div id="krot-dash" style="position:fixed;top:16px;right:16px;z-index:9999;font:12px/1.4 system-ui;background:var(--dsw-alias-bg-layer-3,#1c1c1e);color:var(--dsw-alias-label-primary,#eee);border:1px solid var(--dsw-alias-border-l2,#333);border-radius:10px;padding:8px 12px;box-shadow:0 2px 8px rgba(0,0,0,.3);max-width:280px;display:none;cursor:grab;user-select:none;touch-action:none"></div>' +
261
+ '<script>' +
262
+ '(function(){' +
263
+ 'var el=document.getElementById("krot-dash");if(!el)return;var shown=false;' +
264
+ 'var saved=null;try{saved=JSON.parse(localStorage.getItem("krot-dash-pos")||"null");}catch(e){}' +
265
+ 'if(saved){el.style.left=saved.left+"px";el.style.top=saved.top+"px";el.style.right="auto";}' +
266
+ 'function poll(){' +
267
+ 'fetch("/dsh-key-rotation/health",{headers:{accept:"application/json"}})' +
268
+ '.then(function(r){return r.ok?r.json():null;})' +
269
+ '.then(function(d){if(!d)return;' +
270
+ 'if(!shown){el.style.display="block";shown=true;}' +
271
+ 'var lines=[];' +
272
+ 'for(var name in d.pools){var p=d.pools[name];' +
273
+ 'var color=p.exhausted?"#e5484d":(p.healthy<p.total?"#f5a623":"#30a46c");' +
274
+ 'lines.push(\'<div style="display:flex;align-items:center;gap:6px;margin:2px 0"><span style="width:8px;height:8px;border-radius:50%;background:\'+color+\';flex:none"></span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">\'+name+\'</span><span style="opacity:.6">\'+p.healthy+\'/\'+p.total+\'</span></div>\');' +
275
+ '}' +
276
+ 'el.innerHTML=lines.join("")||\'<div style="opacity:.6">no pools</div>\';' +
277
+ '})' +
278
+ '.catch(function(){});' +
279
+ '}' +
280
+ 'poll();' +
281
+ 'setInterval(poll,4000);' +
282
+ '// drag to move' +
283
+ 'var dragging=false,ox=0,oy=0;' +
284
+ 'el.addEventListener("mousedown",function(e){dragging=true;ox=e.clientX-el.offsetLeft;oy=e.clientY-el.offsetTop;el.style.cursor="grabbing";e.preventDefault();});' +
285
+ 'document.addEventListener("mousemove",function(e){if(!dragging)return;var x=e.clientX-ox,y=e.clientY-oy;x=Math.max(0,Math.min(x,window.innerWidth-el.offsetWidth));y=Math.max(0,Math.min(y,window.innerHeight-el.offsetHeight));el.style.left=x+"px";el.style.top=y+"px";el.style.right="auto";});' +
286
+ 'document.addEventListener("mouseup",function(){if(!dragging)return;dragging=false;el.style.cursor="grab";try{localStorage.setItem("krot-dash-pos",JSON.stringify({left:el.offsetLeft,top:el.offsetTop}));}catch(e){}});' +
287
+ '})();' +
288
+ '</script>';
289
+ ctx.effect(() => ctx.webServer.tapIndex((html) => html + DASH_HTML), 'dsh-key-rotation: dashboard');
290
+ // ── key-pool state, persisted across config reloads ──
259
291
  // base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
260
292
  const poolState = new Map();
261
293
  // Periodic backup of pools config
@@ -378,6 +410,7 @@ export function apply(ctx, config = {}) {
378
410
  const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
379
411
  const backupKeep = cfg.backupKeep ?? 7;
380
412
  const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
413
+ const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
381
414
 
382
415
  // ref -> pool (every key env of every configured provider)
383
416
  const poolByRef = new Map();
@@ -394,7 +427,7 @@ export function apply(ctx, config = {}) {
394
427
  st = {
395
428
  failedUntil: new Map(), failCounts: new Map(), pointer: 0, lastUsed: undefined,
396
429
  switches: 0, lastReason: undefined, lastSwitchAt: undefined,
397
- lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(),
430
+ lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(), byModel: new Map(), usageDays: new Map(),
398
431
  };
399
432
  poolState.set(base, st);
400
433
  }
@@ -462,7 +495,7 @@ export function apply(ctx, config = {}) {
462
495
  for (const key of [...poolState.keys()]) {
463
496
  if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
464
497
  }
465
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
498
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
466
499
  }
467
500
 
468
501
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -603,6 +636,34 @@ export function apply(ctx, config = {}) {
603
636
  const c = Number(chunk.usage.cost);
604
637
  if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
605
638
  }
639
+ // Usage by day (#119)
640
+ if (pool.state.lastUsed) {
641
+ if (!pool.state.usageDays) pool.state.usageDays = new Map();
642
+ const day = new Date().toISOString().slice(0, 10);
643
+ const dayMap = pool.state.usageDays.get(pool.state.lastUsed) || new Map();
644
+ dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
645
+ pool.state.usageDays.set(pool.state.lastUsed, dayMap);
646
+ }
647
+ // Per-model request detail (#121)
648
+ if (pool.state.lastUsed && options.model) {
649
+ if (!pool.state.byModel) pool.state.byModel = new Map();
650
+ let byRef = pool.state.byModel.get(pool.state.lastUsed);
651
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(pool.state.lastUsed, byRef); }
652
+ byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
653
+ }
654
+ // Proactive rate-limit (#115): if response headers say this key is near
655
+ // its quota, cool it down so the NEXT request starts on a different key.
656
+ // We do NOT re-run this (already successful) request — that would double-send.
657
+ const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
658
+ if (rate && pool.state.lastUsed) {
659
+ const { rateLimitThreshold } = buildRuntime();
660
+ if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
661
+ const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
662
+ recordFailure(pool, pool.state.lastUsed, Date.now(), cool, pool.maxCooldownMs);
663
+ pushEvent(pool, pool.state.lastUsed, 'RATE_LIMIT', cool);
664
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
665
+ }
666
+ }
606
667
  yield chunk;
607
668
  return;
608
669
  }
@@ -699,6 +760,8 @@ export function apply(ctx, config = {}) {
699
760
  active: pool.state.lastUsed === ref,
700
761
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
701
762
  usage: pool.state.usageCounts?.get(ref) ?? 0,
763
+ byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
764
+ usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
702
765
  cost: pool.state.costPerKey?.get(ref) ?? 0,
703
766
  lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
704
767
  expiresAt: pool.expiresAt?.[ref] ?? null,
package/lib/pool.js CHANGED
@@ -200,3 +200,28 @@ export function computeHealthScore(state) {
200
200
  const broken = state.brokenUntil ? state.brokenUntil.size : 0;
201
201
  return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
202
202
  }
203
+
204
+ /** Extract rate-limit info from an object that may carry response headers.
205
+ * Looks for X-RateLimit-Remaining / X-RateLimit-Limit / X-RateLimit-Reset
206
+ * (case-insensitive) in headers. Returns { remaining, limit, reset } or null. */
207
+ export function extractRateLimit(headers) {
208
+ if (!headers || typeof headers !== 'object') return null;
209
+ const get = (name) => {
210
+ const v = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
211
+ if (v === undefined || v === null) return undefined;
212
+ return Number(String(v));
213
+ };
214
+ const remaining = get('X-RateLimit-Remaining');
215
+ const limit = get('X-RateLimit-Limit');
216
+ const reset = get('X-RateLimit-Reset');
217
+ if (remaining === undefined && limit === undefined) return null;
218
+ return { remaining, limit, reset };
219
+ }
220
+
221
+ /** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
222
+ export function isRateLimited(rate, threshold = 0.1) {
223
+ if (!rate) return false;
224
+ if (rate.remaining === undefined) return false;
225
+ if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
226
+ return rate.remaining <= 0;
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
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",