@goodandready/dsh-key-rotation 0.7.13 → 0.7.14

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,31 @@ 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;bottom: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"></div>' +
261
+ '<script>' +
262
+ '(function(){' +
263
+ 'var el=document.getElementById("krot-dash");if(!el)return;var shown=false;' +
264
+ 'function poll(){' +
265
+ 'fetch("/dsh-key-rotation/health",{headers:{accept:"application/json"}})' +
266
+ '.then(function(r){return r.ok?r.json():null;})' +
267
+ '.then(function(d){if(!d)return;' +
268
+ 'if(!shown){el.style.display="block";shown=true;}' +
269
+ 'var lines=[];' +
270
+ 'for(var name in d.pools){var p=d.pools[name];' +
271
+ 'var color=p.exhausted?"#e5484d":(p.healthy<p.total?"#f5a623":"#30a46c");' +
272
+ '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>\');' +
273
+ '}' +
274
+ 'el.innerHTML=lines.join("")||\'<div style="opacity:.6">no pools</div>\';' +
275
+ '})' +
276
+ '.catch(function(){});' +
277
+ '}' +
278
+ 'poll();' +
279
+ 'setInterval(poll,4000);' +
280
+ '})();' +
281
+ '</script>';
282
+ ctx.effect(() => ctx.webServer.tapIndex((html) => html + DASH_HTML), 'dsh-key-rotation: dashboard');
283
+ // ── key-pool state, persisted across config reloads ──
259
284
  // base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
260
285
  const poolState = new Map();
261
286
  // Periodic backup of pools config
@@ -378,6 +403,7 @@ export function apply(ctx, config = {}) {
378
403
  const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
379
404
  const backupKeep = cfg.backupKeep ?? 7;
380
405
  const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
406
+ const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
381
407
 
382
408
  // ref -> pool (every key env of every configured provider)
383
409
  const poolByRef = new Map();
@@ -394,7 +420,7 @@ export function apply(ctx, config = {}) {
394
420
  st = {
395
421
  failedUntil: new Map(), failCounts: new Map(), pointer: 0, lastUsed: undefined,
396
422
  switches: 0, lastReason: undefined, lastSwitchAt: undefined,
397
- lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(),
423
+ lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(), byModel: new Map(), usageDays: new Map(),
398
424
  };
399
425
  poolState.set(base, st);
400
426
  }
@@ -462,7 +488,7 @@ export function apply(ctx, config = {}) {
462
488
  for (const key of [...poolState.keys()]) {
463
489
  if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
464
490
  }
465
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
491
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
466
492
  }
467
493
 
468
494
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -603,6 +629,34 @@ export function apply(ctx, config = {}) {
603
629
  const c = Number(chunk.usage.cost);
604
630
  if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
605
631
  }
632
+ // Usage by day (#119)
633
+ if (pool.state.lastUsed) {
634
+ if (!pool.state.usageDays) pool.state.usageDays = new Map();
635
+ const day = new Date().toISOString().slice(0, 10);
636
+ const dayMap = pool.state.usageDays.get(pool.state.lastUsed) || new Map();
637
+ dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
638
+ pool.state.usageDays.set(pool.state.lastUsed, dayMap);
639
+ }
640
+ // Per-model request detail (#121)
641
+ if (pool.state.lastUsed && options.model) {
642
+ if (!pool.state.byModel) pool.state.byModel = new Map();
643
+ let byRef = pool.state.byModel.get(pool.state.lastUsed);
644
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(pool.state.lastUsed, byRef); }
645
+ byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
646
+ }
647
+ // Proactive rate-limit (#115): if response headers say this key is near
648
+ // its quota, cool it down so the NEXT request starts on a different key.
649
+ // We do NOT re-run this (already successful) request — that would double-send.
650
+ const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
651
+ if (rate && pool.state.lastUsed) {
652
+ const { rateLimitThreshold } = buildRuntime();
653
+ if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
654
+ const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
655
+ recordFailure(pool, pool.state.lastUsed, Date.now(), cool, pool.maxCooldownMs);
656
+ pushEvent(pool, pool.state.lastUsed, 'RATE_LIMIT', cool);
657
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
658
+ }
659
+ }
606
660
  yield chunk;
607
661
  return;
608
662
  }
@@ -699,6 +753,8 @@ export function apply(ctx, config = {}) {
699
753
  active: pool.state.lastUsed === ref,
700
754
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
701
755
  usage: pool.state.usageCounts?.get(ref) ?? 0,
756
+ byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
757
+ usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
702
758
  cost: pool.state.costPerKey?.get(ref) ?? 0,
703
759
  lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
704
760
  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.14",
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",