@goodandready/dsh-key-rotation 0.7.12 → 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 +14 -1
- package/lib/index.js +64 -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,31 @@ 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;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
|
|
@@ -374,6 +399,11 @@ export function apply(ctx, config = {}) {
|
|
|
374
399
|
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
375
400
|
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
376
401
|
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
402
|
+
const backupDir = cfg.backupDir ?? '';
|
|
403
|
+
const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
|
|
404
|
+
const backupKeep = cfg.backupKeep ?? 7;
|
|
405
|
+
const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
|
|
406
|
+
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
377
407
|
|
|
378
408
|
// ref -> pool (every key env of every configured provider)
|
|
379
409
|
const poolByRef = new Map();
|
|
@@ -390,7 +420,7 @@ export function apply(ctx, config = {}) {
|
|
|
390
420
|
st = {
|
|
391
421
|
failedUntil: new Map(), failCounts: new Map(), pointer: 0, lastUsed: undefined,
|
|
392
422
|
switches: 0, lastReason: undefined, lastSwitchAt: undefined,
|
|
393
|
-
lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(),
|
|
423
|
+
lastExhaustionAt: undefined, exhaustionCount: 0, events: [], usageCounts: new Map(), byModel: new Map(), usageDays: new Map(),
|
|
394
424
|
};
|
|
395
425
|
poolState.set(base, st);
|
|
396
426
|
}
|
|
@@ -458,7 +488,7 @@ export function apply(ctx, config = {}) {
|
|
|
458
488
|
for (const key of [...poolState.keys()]) {
|
|
459
489
|
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
460
490
|
}
|
|
461
|
-
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 };
|
|
462
492
|
}
|
|
463
493
|
|
|
464
494
|
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
@@ -599,6 +629,34 @@ export function apply(ctx, config = {}) {
|
|
|
599
629
|
const c = Number(chunk.usage.cost);
|
|
600
630
|
if (!isNaN(c)) pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
|
|
601
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
|
+
}
|
|
602
660
|
yield chunk;
|
|
603
661
|
return;
|
|
604
662
|
}
|
|
@@ -695,6 +753,8 @@ export function apply(ctx, config = {}) {
|
|
|
695
753
|
active: pool.state.lastUsed === ref,
|
|
696
754
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
697
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)) : {},
|
|
698
758
|
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
699
759
|
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
700
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.
|
|
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",
|