@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 +14 -1
- package/lib/index.js +60 -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
|
|
@@ -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.
|
|
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",
|