@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 +14 -1
- package/lib/index.js +67 -4
- package/lib/pool.js +25 -0
- package/package.json +1 -1
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)
|
|
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
|
-
//
|
|
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.
|
|
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",
|