@goodandready/dsh-key-rotation 0.8.25 → 0.8.28
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/CHANGELOG.md +36 -0
- package/lib/cascade.js +14 -6
- package/lib/client.js +170 -6
- package/lib/concurrency.js +6 -4
- package/lib/crypto-storage.js +49 -0
- package/lib/http-bridge.js +1 -0
- package/lib/index.js +4 -4
- package/lib/ops-metrics.js +80 -0
- package/lib/ops-paths.js +1 -0
- package/lib/pool-builder.js +21 -0
- package/lib/pool.js +67 -17
- package/lib/rotate.js +32 -15
- package/lib/routes-ops.js +2 -0
- package/lib/webhook.js +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
+
## 0.8.28 - 2026-09-27
|
|
7
|
+
|
|
8
|
+
### Security & Key Lifecycle (Block 3)
|
|
9
|
+
- **Permanent Key Revocation (#369)**:
|
|
10
|
+
- Added `revokedRefs` tracking in pool state and configuration. Keys returning permanent auth failures (HTTP 401 / `AUTH_FAILED`) are permanently revoked and excluded from rotation and cascade attempts.
|
|
11
|
+
- Added visual revoked status badges and tokens in WebUI (`lib/client.js`).
|
|
12
|
+
- **Key Encryption at Rest (#370)**:
|
|
13
|
+
- Added `lib/crypto-storage.js` implementing authenticated AES-256-GCM encryption with 12-byte IV and 16-byte auth tag.
|
|
14
|
+
- Transparently uses environment variable `DSH_KEY_SECRET` when configured, with safe plaintext fallback when unset.
|
|
15
|
+
- **Key Expiration Tracker (#371)**:
|
|
16
|
+
- Added automated expiration date tracking (`expiresAt`), horizon detection (`expiringSoon`), 24-hour notification deduplication (`shouldNotifyDaily`), and automated exclusion of expired keys from active routing.
|
|
17
|
+
|
|
18
|
+
### Monitoring & Integrations (Block 4)
|
|
19
|
+
- **Multi-Platform Webhook Integrations (#372)**:
|
|
20
|
+
- Extended `lib/webhook.js` with native detection and formatting for Gotify (`/message` with JSON priority/extras) and Ntfy (`/alerts` with action buttons).
|
|
21
|
+
- **Prometheus Metrics Exporter (#373)**:
|
|
22
|
+
- Added `/dsh-key-rotation/metrics` route (`lib/ops-metrics.js`) exposing OpenMetrics / Prometheus standard text format with gauges for pool totals, active/cooldown/paused/revoked keys, and counters for proactive switches and cascades. Guarded by loopback origin fail-closed security.
|
|
23
|
+
|
|
24
|
+
## 0.8.27 - 2026-09-27
|
|
25
|
+
|
|
26
|
+
### Smart Routing (Block 1)
|
|
27
|
+
- **Proactive Rate-Limit Cooldown (#363)**:
|
|
28
|
+
- Proactive key cooldown based on upstream rate limit reset headers (`x-ratelimit-reset`, `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`) supporting time deltas and ISO timestamps.
|
|
29
|
+
- **Cross-Provider Cascading (#364)**:
|
|
30
|
+
- Cascade routing across providers on pool exhaustion with `modelMapping` and max cascade depth guard.
|
|
31
|
+
- **Adaptive Concurrency Normalization (#365)**:
|
|
32
|
+
- Weighted least-loaded concurrency balancing (`inflight / weight`).
|
|
33
|
+
|
|
34
|
+
### WebUI Suite (Block 2)
|
|
35
|
+
- **Bulk Key Import (#366)**:
|
|
36
|
+
- WebUI modal for bulk key import supporting line-by-line, `.env`, and CSV formats.
|
|
37
|
+
- **Key Maintenance Toggle (#367)**:
|
|
38
|
+
- Live key pause/resume toggle (`isPaused`) in WebUI.
|
|
39
|
+
- **Traffic Sparklines (#368)**:
|
|
40
|
+
- Native SVG Sparkline component for request and latency visual trends.
|
|
41
|
+
|
|
6
42
|
## 0.8.25 - 2026-09-27
|
|
7
43
|
|
|
8
44
|
### Fixed & Hardened
|
package/lib/cascade.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
// cascade.js — cross-provider failover cascade (
|
|
2
|
-
// ponytail: minimal — pick fallback provider
|
|
1
|
+
// cascade.js — cross-provider failover cascade (issues #194, #364).
|
|
2
|
+
// ponytail: minimal — pick fallback provider with model mapping from config.
|
|
3
3
|
|
|
4
|
-
export const CASCADE_MAX_DEPTH =
|
|
4
|
+
export const CASCADE_MAX_DEPTH = 3;
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function isKeyPaused(pool, ref) {
|
|
7
|
+
if (!pool || !ref) return false;
|
|
8
|
+
if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
|
|
9
|
+
if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function pickCascadeFallback(provider, cfg, pools, currentModel) {
|
|
7
14
|
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
8
15
|
for (const entry of list) {
|
|
9
16
|
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
@@ -13,6 +20,7 @@ export function pickCascadeFallback(provider, cfg, pools) {
|
|
|
13
20
|
const now = Date.now();
|
|
14
21
|
let healthy = 0;
|
|
15
22
|
for (const ref of pool.refs) {
|
|
23
|
+
if (isKeyPaused(pool, ref) || (pool.revokedRefs && pool.revokedRefs.has(ref)) || (pool.state?.revokedRefs && pool.state.revokedRefs.has(ref))) continue;
|
|
16
24
|
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
17
25
|
if (failedUntil > now) continue;
|
|
18
26
|
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
@@ -20,8 +28,8 @@ export function pickCascadeFallback(provider, cfg, pools) {
|
|
|
20
28
|
healthy += 1;
|
|
21
29
|
}
|
|
22
30
|
if (healthy === 0) continue;
|
|
23
|
-
|
|
31
|
+
const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
|
|
32
|
+
return { provider: fb.provider, pool, model: mappedModel };
|
|
24
33
|
}
|
|
25
34
|
return null;
|
|
26
35
|
}
|
|
27
|
-
|
package/lib/client.js
CHANGED
|
@@ -78,6 +78,17 @@ window.__ModuleLoader__.load({
|
|
|
78
78
|
addKey: '+ Add key',
|
|
79
79
|
addKeyTitle: 'Add API key',
|
|
80
80
|
noProviders: 'No providers registered with DSH — nothing to pick from yet.',
|
|
81
|
+
bulkImport: 'Bulk Import',
|
|
82
|
+
bulkImportDesc: 'Paste API keys line-by-line, as .env (KEY=value), or CSV (KEY, weight):',
|
|
83
|
+
bulkImportBtn: 'Import Keys',
|
|
84
|
+
bulkImportCancel: 'Cancel',
|
|
85
|
+
bulkImportSuccess: 'Imported {n} keys',
|
|
86
|
+
keyPaused: 'Paused',
|
|
87
|
+
keyRevoked: 'Revoked (401)',
|
|
88
|
+
pauseKey: 'Pause key from rotation',
|
|
89
|
+
resumeKey: 'Resume key rotation',
|
|
90
|
+
sparklineTraffic: 'Traffic Trend',
|
|
91
|
+
sparklineLatency: 'Latency Trend',
|
|
81
92
|
addProvider: '+ Add provider',
|
|
82
93
|
desc: 'Per-provider API key rotation. For each provider, list its API keys (env names, stored in DSH credentials). The plugin routes a model through that provider\u2019s keys in order and switches to the next on a quota/rate-limit failure.',
|
|
83
94
|
cooldown: 'Cooldown after failure (ms)',
|
|
@@ -223,6 +234,17 @@ window.__ModuleLoader__.load({
|
|
|
223
234
|
addKey: '+ 添加密钥',
|
|
224
235
|
addKeyTitle: '添加 API 密钥',
|
|
225
236
|
noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
|
|
237
|
+
bulkImport: '批量导入',
|
|
238
|
+
bulkImportDesc: '按行粘贴 API 密钥,支持 .env (KEY=value) 或 CSV (KEY, 权重) 格式:',
|
|
239
|
+
bulkImportBtn: '导入密钥',
|
|
240
|
+
bulkImportCancel: '取消',
|
|
241
|
+
bulkImportSuccess: '已导入 {n} 个密钥',
|
|
242
|
+
keyPaused: '已暂停',
|
|
243
|
+
keyRevoked: '已撤销 (401)',
|
|
244
|
+
pauseKey: '暂停此密钥轮换',
|
|
245
|
+
resumeKey: '恢复此密钥轮换',
|
|
246
|
+
sparklineTraffic: '流量趋势',
|
|
247
|
+
sparklineLatency: '延迟趋势',
|
|
226
248
|
addProvider: '+ 添加提供商',
|
|
227
249
|
desc: '按提供商的 API 密钥轮换。为每个提供商列出其 API 密钥(环境变量名,存储在 DSH 凭据中)。插件按顺序通过提供商的密钥路由模型,并在配额/速率限制失败时切换到下一个。',
|
|
228
250
|
cooldown: '故障后冷却时间 (毫秒)',
|
|
@@ -590,6 +612,74 @@ window.__ModuleLoader__.load({
|
|
|
590
612
|
};
|
|
591
613
|
}
|
|
592
614
|
|
|
615
|
+
|
|
616
|
+
function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, #3b82f6)' }) {
|
|
617
|
+
if (!Array.isArray(points) || points.length < 2) {
|
|
618
|
+
return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
|
|
619
|
+
h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const min = Math.min(...points);
|
|
623
|
+
const max = Math.max(...points);
|
|
624
|
+
const range = max - min || 1;
|
|
625
|
+
const step = width / (points.length - 1);
|
|
626
|
+
const coords = points.map((p, i) => {
|
|
627
|
+
const x = Math.round(i * step * 10) / 10;
|
|
628
|
+
const y = Math.round((height - ((p - min) / range) * (height - 4) - 2) * 10) / 10;
|
|
629
|
+
return `${x},${y}`;
|
|
630
|
+
}).join(' ');
|
|
631
|
+
|
|
632
|
+
return h('svg', {
|
|
633
|
+
width,
|
|
634
|
+
height,
|
|
635
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
636
|
+
style: { display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' },
|
|
637
|
+
}, h('polyline', {
|
|
638
|
+
points: coords,
|
|
639
|
+
fill: 'none',
|
|
640
|
+
stroke,
|
|
641
|
+
strokeWidth: 2,
|
|
642
|
+
strokeLinecap: 'round',
|
|
643
|
+
strokeLinejoin: 'round',
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function parseBulkKeys(text) {
|
|
648
|
+
if (!text || typeof text !== 'string') return [];
|
|
649
|
+
const lines = text.split(/\r?\n/);
|
|
650
|
+
const result = [];
|
|
651
|
+
const seen = new Set();
|
|
652
|
+
for (let raw of lines) {
|
|
653
|
+
const line = raw.trim();
|
|
654
|
+
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
|
655
|
+
const envMatch = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
656
|
+
if (envMatch) {
|
|
657
|
+
const ref = envMatch[1];
|
|
658
|
+
if (!seen.has(ref)) {
|
|
659
|
+
seen.add(ref);
|
|
660
|
+
let weight = 1;
|
|
661
|
+
const rest = envMatch[2].trim().replace(/^['"]|['"]$/g, '');
|
|
662
|
+
const wMatch = rest.match(/,\s*(?:weight\s*=\s*)?([0-9]+)$/i);
|
|
663
|
+
if (wMatch) weight = Math.max(1, parseInt(wMatch[1], 10) || 1);
|
|
664
|
+
result.push({ ref, weight });
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const parts = line.split(',');
|
|
669
|
+
const ref = parts[0].trim();
|
|
670
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref) && !seen.has(ref)) {
|
|
671
|
+
seen.add(ref);
|
|
672
|
+
let weight = 1;
|
|
673
|
+
if (parts[1]) {
|
|
674
|
+
const wm = parts[1].trim().match(/(?:weight\s*=\s*)?([0-9]+)/i);
|
|
675
|
+
if (wm) weight = Math.max(1, parseInt(wm[1], 10) || 1);
|
|
676
|
+
}
|
|
677
|
+
result.push({ ref, weight });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
|
|
593
683
|
function UpdaterSection({ t }) {
|
|
594
684
|
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
595
685
|
const load = React.useCallback(async () => {
|
|
@@ -883,6 +973,47 @@ window.__ModuleLoader__.load({
|
|
|
883
973
|
const [selected, setSelected] = React.useState(new Set());
|
|
884
974
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
885
975
|
const [undo, setUndo] = React.useState(null);
|
|
976
|
+
const [bulkOpen, setBulkOpen] = React.useState({});
|
|
977
|
+
const [bulkText, setBulkText] = React.useState({});
|
|
978
|
+
|
|
979
|
+
const togglePauseKey = (pIndex, kIndex) => setField((cur) => {
|
|
980
|
+
const providers = [...(cur.providers ?? [])];
|
|
981
|
+
const ent = { ...providers[pIndex] };
|
|
982
|
+
const paused = [...(ent.paused ?? [])];
|
|
983
|
+
paused[kIndex] = !paused[kIndex];
|
|
984
|
+
ent.paused = paused;
|
|
985
|
+
providers[pIndex] = ent;
|
|
986
|
+
return { ...cur, providers };
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const applyBulkImport = (pIndex) => {
|
|
990
|
+
const text = bulkText[pIndex] || '';
|
|
991
|
+
const parsed = parseBulkKeys(text);
|
|
992
|
+
if (!parsed.length) return;
|
|
993
|
+
setField((cur) => {
|
|
994
|
+
const providers = [...(cur.providers ?? [])];
|
|
995
|
+
const ent = { ...providers[pIndex] };
|
|
996
|
+
const existingKeys = new Set(ent.keys ?? []);
|
|
997
|
+
const nextKeys = [...(ent.keys ?? [])];
|
|
998
|
+
const nextWeights = [...(ent.weights ?? [])];
|
|
999
|
+
const nextPaused = [...(ent.paused ?? [])];
|
|
1000
|
+
for (const item of parsed) {
|
|
1001
|
+
if (!existingKeys.has(item.ref)) {
|
|
1002
|
+
existingKeys.add(item.ref);
|
|
1003
|
+
nextKeys.push(item.ref);
|
|
1004
|
+
nextWeights.push(item.weight);
|
|
1005
|
+
nextPaused.push(false);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
ent.keys = nextKeys;
|
|
1009
|
+
ent.weights = nextWeights;
|
|
1010
|
+
ent.paused = nextPaused;
|
|
1011
|
+
providers[pIndex] = ent;
|
|
1012
|
+
return { ...cur, providers };
|
|
1013
|
+
});
|
|
1014
|
+
setBulkText((cur) => ({ ...cur, [pIndex]: '' }));
|
|
1015
|
+
setBulkOpen((cur) => ({ ...cur, [pIndex]: false }));
|
|
1016
|
+
};
|
|
886
1017
|
const undoTimer = React.useRef(null);
|
|
887
1018
|
React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
|
|
888
1019
|
// #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
|
|
@@ -1451,7 +1582,8 @@ window.__ModuleLoader__.load({
|
|
|
1451
1582
|
|
|
1452
1583
|
// Key state dot: color and label read at a glance; "key not found"
|
|
1453
1584
|
// catches an env-name typo that would otherwise stay silent.
|
|
1454
|
-
const keyStatus = (providerId, ref) => {
|
|
1585
|
+
const keyStatus = (providerId, ref, isPaused = false) => {
|
|
1586
|
+
if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused') };
|
|
1455
1587
|
const hit = keyInfo(providerId, ref);
|
|
1456
1588
|
if (!hit) return null;
|
|
1457
1589
|
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
@@ -1460,6 +1592,7 @@ window.__ModuleLoader__.load({
|
|
|
1460
1592
|
const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
|
|
1461
1593
|
if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
|
|
1462
1594
|
}
|
|
1595
|
+
if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
|
|
1463
1596
|
if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
|
|
1464
1597
|
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
1465
1598
|
if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
|
|
@@ -1484,10 +1617,12 @@ window.__ModuleLoader__.load({
|
|
|
1484
1617
|
|
|
1485
1618
|
const keys = entry.keys ?? [];
|
|
1486
1619
|
const entryWeights = entry.weights ?? [];
|
|
1620
|
+
const entryPaused = entry.paused ?? [];
|
|
1487
1621
|
|
|
1488
1622
|
let readyCount = 0, cooldownCount = 0, errorCount = 0;
|
|
1489
|
-
for (
|
|
1490
|
-
const
|
|
1623
|
+
for (let idx = 0; idx < keys.length; idx++) {
|
|
1624
|
+
const k = keys[idx];
|
|
1625
|
+
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1491
1626
|
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
1492
1627
|
const tr = testResult[k];
|
|
1493
1628
|
if (tr && !tr.ok) errorCount++;
|
|
@@ -1495,9 +1630,9 @@ window.__ModuleLoader__.load({
|
|
|
1495
1630
|
else readyCount++;
|
|
1496
1631
|
}
|
|
1497
1632
|
|
|
1498
|
-
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k }) => {
|
|
1633
|
+
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
|
|
1499
1634
|
if (statusFilter === 'all') return true;
|
|
1500
|
-
const st = keyStatus(entry.provider, k);
|
|
1635
|
+
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1501
1636
|
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
1502
1637
|
const tr = testResult[k];
|
|
1503
1638
|
if (statusFilter === 'ready') return !isCool;
|
|
@@ -1514,7 +1649,8 @@ window.__ModuleLoader__.load({
|
|
|
1514
1649
|
) : null;
|
|
1515
1650
|
|
|
1516
1651
|
const keyRows = filteredIndices.map(({ key, kIndex }) => {
|
|
1517
|
-
const
|
|
1652
|
+
const isP = Boolean(entryPaused[kIndex]);
|
|
1653
|
+
const st = keyStatus(entry.provider, key, isP);
|
|
1518
1654
|
const info = keyInfo(entry.provider, key);
|
|
1519
1655
|
const rowKey = entry.provider + '/' + key;
|
|
1520
1656
|
const typed = secretDraft[rowKey];
|
|
@@ -1570,6 +1706,14 @@ window.__ModuleLoader__.load({
|
|
|
1570
1706
|
title: t('weightHint'),
|
|
1571
1707
|
onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
|
|
1572
1708
|
style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
|
|
1709
|
+
meta.push(h('button', {
|
|
1710
|
+
key: 'pause-btn',
|
|
1711
|
+
type: 'button',
|
|
1712
|
+
className: 'krot-btn' + (isP ? ' krot-pill-warn' : ''),
|
|
1713
|
+
title: isP ? t('resumeKey') : t('pauseKey'),
|
|
1714
|
+
style: { padding: '2px 6px', fontSize: '11px' },
|
|
1715
|
+
onClick: () => togglePauseKey(pIndex, kIndex),
|
|
1716
|
+
}, isP ? '⏸ ' + t('keyPaused') : '▶'));
|
|
1573
1717
|
// #210: RPM capacity indicator (only when rpmLimit is active)
|
|
1574
1718
|
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
1575
1719
|
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
@@ -1701,6 +1845,10 @@ window.__ModuleLoader__.load({
|
|
|
1701
1845
|
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('keyLabel').replace('{n}', String(x.index + 1)) + ': ' + x.usage + ' (' + pct + '%)')
|
|
1702
1846
|
);
|
|
1703
1847
|
})
|
|
1848
|
+
) : null,
|
|
1849
|
+
total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1850
|
+
h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
|
|
1851
|
+
h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
|
|
1704
1852
|
) : null
|
|
1705
1853
|
);
|
|
1706
1854
|
})();
|
|
@@ -1773,6 +1921,22 @@ window.__ModuleLoader__.load({
|
|
|
1773
1921
|
h(UpdaterSection, { t }),
|
|
1774
1922
|
h('div', { className: 'krot-foot' },
|
|
1775
1923
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
|
|
1924
|
+
btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
|
|
1925
|
+
bulkOpen[pIndex] ? h('div', { className: 'krot-bulk-import-box', style: { width: '100%', marginTop: '8px', padding: '8px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '6px' } },
|
|
1926
|
+
h('p', { style: { margin: '0 0 6px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('bulkImportDesc')),
|
|
1927
|
+
h('textarea', {
|
|
1928
|
+
className: 'krot-in',
|
|
1929
|
+
rows: 4,
|
|
1930
|
+
style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical' },
|
|
1931
|
+
placeholder: 'KEY_1\nKEY_2, weight=5\nOPENAI_KEY=sk-...',
|
|
1932
|
+
value: bulkText[pIndex] || '',
|
|
1933
|
+
onChange: (e) => setBulkText(cur => ({ ...cur, [pIndex]: e.target.value })),
|
|
1934
|
+
}),
|
|
1935
|
+
h('div', { style: { display: 'flex', gap: '8px', marginTop: '6px' } },
|
|
1936
|
+
btn(t('bulkImportBtn'), () => applyBulkImport(pIndex), { primary: true }),
|
|
1937
|
+
btn(t('bulkImportCancel'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: false })))
|
|
1938
|
+
)
|
|
1939
|
+
) : null,
|
|
1776
1940
|
switchesLine,
|
|
1777
1941
|
budgetLine,
|
|
1778
1942
|
sloLine,
|
package/lib/concurrency.js
CHANGED
|
@@ -55,19 +55,21 @@ export class ConcurrencyTracker {
|
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
pickLeastLoaded(candidates, now) {
|
|
58
|
+
pickLeastLoaded(candidates, now, weights) {
|
|
59
59
|
now = now || Date.now();
|
|
60
60
|
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
61
61
|
let best = null;
|
|
62
|
-
let
|
|
62
|
+
let bestScore = Infinity;
|
|
63
63
|
for (const ref of candidates) {
|
|
64
64
|
const e = this._inFlight.get(ref);
|
|
65
65
|
let count = e ? e.count : 0;
|
|
66
66
|
if (e && now - e.lastAcquired > this._staleMs) count = 0;
|
|
67
67
|
if (this.isEnabled() && count >= this._limit) continue;
|
|
68
|
-
|
|
68
|
+
const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
|
|
69
|
+
const score = count / w;
|
|
70
|
+
if (score < bestScore) {
|
|
69
71
|
best = ref;
|
|
70
|
-
|
|
72
|
+
bestScore = score;
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
return best;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// lib/crypto-storage.js — AES-256-GCM encryption for credentials at rest (#370)
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const PREFIX = 'enc:v1:';
|
|
5
|
+
|
|
6
|
+
function getDerivedKey(secret) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(secret)).digest();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Encrypt plaintext using AES-256-GCM if secretKey is provided.
|
|
12
|
+
* If secretKey is not set, returns plaintext as-is.
|
|
13
|
+
*/
|
|
14
|
+
export function encryptSecret(plaintext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
15
|
+
if (!secretKey || !plaintext || typeof plaintext !== 'string') return plaintext;
|
|
16
|
+
if (plaintext.startsWith(PREFIX)) return plaintext; // already encrypted
|
|
17
|
+
const key = getDerivedKey(secretKey);
|
|
18
|
+
const iv = crypto.randomBytes(12);
|
|
19
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
20
|
+
let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
|
|
21
|
+
ciphertext += cipher.final('hex');
|
|
22
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
23
|
+
return `${PREFIX}${iv.toString('hex')}:${authTag}:${ciphertext}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decrypt ciphertext using AES-256-GCM if prefixed with enc:v1:.
|
|
28
|
+
* If not encrypted, or if secretKey is missing/incorrect, returns safe fallback.
|
|
29
|
+
*/
|
|
30
|
+
export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
31
|
+
if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
|
|
32
|
+
if (!ciphertext.startsWith(PREFIX)) return ciphertext; // plaintext fallback
|
|
33
|
+
if (!secretKey) return ciphertext;
|
|
34
|
+
try {
|
|
35
|
+
const parts = ciphertext.slice(PREFIX.length).split(':');
|
|
36
|
+
if (parts.length !== 3) return ciphertext;
|
|
37
|
+
const [ivHex, tagHex, dataHex] = parts;
|
|
38
|
+
const key = getDerivedKey(secretKey);
|
|
39
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
40
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
41
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
42
|
+
decipher.setAuthTag(authTag);
|
|
43
|
+
let decrypted = decipher.update(dataHex, 'hex', 'utf8');
|
|
44
|
+
decrypted += decipher.final('utf8');
|
|
45
|
+
return decrypted;
|
|
46
|
+
} catch {
|
|
47
|
+
return ciphertext;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/http-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
|
|
2
2
|
import { findSecrets } from './keycheck.js';
|
|
3
3
|
import { isTrustedBridgeRequest } from './pool.js';
|
|
4
|
+
export { isTrustedBridgeRequest };
|
|
4
5
|
import { mutateSettings, plainObject, requireRevision, sectionOps, validateOps } from './settings-write.js';
|
|
5
6
|
|
|
6
7
|
export const NS = 'dsh-key-rotation';
|
package/lib/index.js
CHANGED
|
@@ -263,13 +263,13 @@ export function apply(ctx, config = {}) {
|
|
|
263
263
|
return initializePoolState(st);
|
|
264
264
|
};
|
|
265
265
|
|
|
266
|
-
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
267
|
-
buildPoolItem({ base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
266
|
+
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
267
|
+
buildPoolItem({ base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
268
268
|
|
|
269
269
|
for (const p of cfg.providers ?? []) {
|
|
270
270
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
271
271
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
272
|
-
const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
272
|
+
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
273
273
|
if (pool) {
|
|
274
274
|
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
275
275
|
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
@@ -277,7 +277,7 @@ export function apply(ctx, config = {}) {
|
|
|
277
277
|
const models = p.models ?? {};
|
|
278
278
|
const byModel = new Map();
|
|
279
279
|
for (const [model, mp] of Object.entries(models)) {
|
|
280
|
-
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
280
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
281
281
|
if (mpool) {
|
|
282
282
|
byModel.set(model, mpool);
|
|
283
283
|
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
|
|
2
|
+
import { METRICS_PATH } from './ops-paths.js';
|
|
3
|
+
import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} ctx cordis context
|
|
7
|
+
* @param {object} deps live dependencies from apply()
|
|
8
|
+
*/
|
|
9
|
+
export function registerMetricsRoutes(ctx, deps) {
|
|
10
|
+
const { buildRuntime, poolState } = deps;
|
|
11
|
+
|
|
12
|
+
ctx.effect(() => ctx.webServer.register({
|
|
13
|
+
kind: 'exact',
|
|
14
|
+
path: METRICS_PATH,
|
|
15
|
+
handler: async (req, res) => {
|
|
16
|
+
if (req.method !== 'GET') {
|
|
17
|
+
res.statusCode = 405;
|
|
18
|
+
res.setHeader('content-type', 'text/plain');
|
|
19
|
+
res.end('Method Not Allowed\n');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
23
|
+
res.statusCode = 403;
|
|
24
|
+
res.setHeader('content-type', 'text/plain');
|
|
25
|
+
res.end('Forbidden: local only\n');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = [];
|
|
30
|
+
const runtime = buildRuntime();
|
|
31
|
+
const pools = runtime.pools ?? [];
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
|
|
34
|
+
lines.push('# HELP dsh_key_rotation_pools_total Total configured provider pools');
|
|
35
|
+
lines.push('# TYPE dsh_key_rotation_pools_total gauge');
|
|
36
|
+
lines.push(`dsh_key_rotation_pools_total ${pools.length}`);
|
|
37
|
+
|
|
38
|
+
lines.push('# HELP dsh_key_rotation_active_keys Number of currently active and ready keys');
|
|
39
|
+
lines.push('# TYPE dsh_key_rotation_active_keys gauge');
|
|
40
|
+
|
|
41
|
+
lines.push('# HELP dsh_key_rotation_cooldown_keys Number of keys in cooldown');
|
|
42
|
+
lines.push('# TYPE dsh_key_rotation_cooldown_keys gauge');
|
|
43
|
+
|
|
44
|
+
lines.push('# HELP dsh_key_rotation_paused_keys Number of paused keys');
|
|
45
|
+
lines.push('# TYPE dsh_key_rotation_paused_keys gauge');
|
|
46
|
+
|
|
47
|
+
lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
|
|
48
|
+
lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
|
|
49
|
+
|
|
50
|
+
lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
|
|
51
|
+
lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
|
|
52
|
+
|
|
53
|
+
lines.push('# HELP dsh_key_rotation_cascade_switches_total Number of multi-vendor cascade switches');
|
|
54
|
+
lines.push('# TYPE dsh_key_rotation_cascade_switches_total counter');
|
|
55
|
+
|
|
56
|
+
for (const pool of pools) {
|
|
57
|
+
const p = pool.base;
|
|
58
|
+
const st = poolState.get(p);
|
|
59
|
+
let active = 0, cooling = 0, paused = 0, revoked = 0;
|
|
60
|
+
for (const r of (pool.refs ?? [])) {
|
|
61
|
+
if (isKeyRevoked(pool, r)) { revoked++; continue; }
|
|
62
|
+
if (isKeyPaused(pool, r)) { paused++; continue; }
|
|
63
|
+
const fu = st?.failedUntil?.get(r) ?? 0;
|
|
64
|
+
if (fu > now) cooling++;
|
|
65
|
+
else active++;
|
|
66
|
+
}
|
|
67
|
+
lines.push(`dsh_key_rotation_active_keys{provider="${p}"} ${active}`);
|
|
68
|
+
lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
|
|
69
|
+
lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
|
|
70
|
+
lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
|
|
71
|
+
lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
|
|
72
|
+
lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
res.statusCode = 200;
|
|
76
|
+
res.setHeader('content-type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
77
|
+
res.end(lines.join('\n') + '\n');
|
|
78
|
+
}
|
|
79
|
+
}), 'dsh-key-rotation: metrics');
|
|
80
|
+
}
|
package/lib/ops-paths.js
CHANGED
|
@@ -9,3 +9,4 @@ export const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
|
9
9
|
export const TEST_PATH = '/dsh-key-rotation/test';
|
|
10
10
|
export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
11
11
|
export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
|
|
12
|
+
export const METRICS_PATH = '/dsh-key-rotation/metrics';
|
package/lib/pool-builder.js
CHANGED
|
@@ -15,6 +15,8 @@ export function buildPoolItem({
|
|
|
15
15
|
base,
|
|
16
16
|
keys,
|
|
17
17
|
weights,
|
|
18
|
+
paused,
|
|
19
|
+
revoked,
|
|
18
20
|
poolCooldown,
|
|
19
21
|
poolMax,
|
|
20
22
|
expiresAt,
|
|
@@ -44,11 +46,30 @@ export function buildPoolItem({
|
|
|
44
46
|
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
49
|
+
const parsedPaused = new Set();
|
|
50
|
+
if (Array.isArray(paused)) {
|
|
51
|
+
for (let i = 0; i < refs.length; i++) {
|
|
52
|
+
if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const parsedRevoked = new Set();
|
|
56
|
+
if (Array.isArray(revoked)) {
|
|
57
|
+
for (let i = 0; i < refs.length; i++) {
|
|
58
|
+
if (revoked[indexed[i].index] === true) parsedRevoked.add(refs[i]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const weightsMap = {};
|
|
62
|
+
for (let i = 0; i < refs.length; i++) {
|
|
63
|
+
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
64
|
+
}
|
|
47
65
|
return {
|
|
48
66
|
base,
|
|
49
67
|
refs,
|
|
50
68
|
weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
69
|
+
weightsMap,
|
|
51
70
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
71
|
+
pausedRefs: parsedPaused,
|
|
72
|
+
revokedRefs: parsedRevoked,
|
|
52
73
|
state: makeState(base),
|
|
53
74
|
cooldownMs: poolCooldown,
|
|
54
75
|
maxCooldownMs: poolMax,
|
package/lib/pool.js
CHANGED
|
@@ -261,24 +261,52 @@ export function computeHealthScore(state) {
|
|
|
261
261
|
return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
|
|
265
|
+
/** Parse duration strings (e.g. '1.5s', '500ms', '2m', '1h'), numbers, or ISO dates into seconds. */
|
|
266
|
+
export function parseDurationToSeconds(v) {
|
|
267
|
+
if (typeof v === 'number') {
|
|
268
|
+
if (!Number.isFinite(v) || v < 0) return null;
|
|
269
|
+
return v;
|
|
270
|
+
}
|
|
271
|
+
if (!v || typeof v !== 'string') return null;
|
|
272
|
+
const s = v.trim();
|
|
273
|
+
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*(ms|s|m|h|d)?$/i);
|
|
274
|
+
if (m) {
|
|
275
|
+
const val = parseFloat(m[1]);
|
|
276
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
277
|
+
if (unit === 'ms') return val / 1000;
|
|
278
|
+
if (unit === 's') return val;
|
|
279
|
+
if (unit === 'm') return val * 60;
|
|
280
|
+
if (unit === 'h') return val * 3600;
|
|
281
|
+
if (unit === 'd') return val * 86400;
|
|
282
|
+
}
|
|
283
|
+
const dt = Date.parse(s);
|
|
284
|
+
if (!Number.isNaN(dt)) {
|
|
285
|
+
return Math.max(0, (dt - Date.now()) / 1000);
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Check if a key reference is in maintenance/paused mode. */
|
|
291
|
+
export function isKeyPaused(pool, ref) {
|
|
292
|
+
if (!pool || !ref) return false;
|
|
293
|
+
if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
|
|
294
|
+
if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
|
|
295
|
+
if (pool.state && pool.state.pausedRefs instanceof Set) return pool.state.pausedRefs.has(ref);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
264
299
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
265
300
|
export function extractRateLimit(headers) {
|
|
266
301
|
if (!headers || typeof headers !== 'object') return null;
|
|
267
|
-
let remaining, limit, reset, retryAfter;
|
|
302
|
+
let remaining, limit, reset, resetMs, retryAfter;
|
|
268
303
|
for (const k in headers) {
|
|
269
304
|
const lower = k.toLowerCase();
|
|
270
305
|
const v = headers[k];
|
|
271
306
|
if (v == null) continue;
|
|
272
307
|
if (lower === 'retry-after') {
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
retryAfter = Math.ceil(num);
|
|
276
|
-
} else {
|
|
277
|
-
const parsed = Date.parse(v);
|
|
278
|
-
if (!Number.isNaN(parsed)) {
|
|
279
|
-
retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
308
|
+
const dur = parseDurationToSeconds(v);
|
|
309
|
+
if (dur !== null) retryAfter = Math.ceil(dur);
|
|
282
310
|
} else if (
|
|
283
311
|
lower === 'x-ratelimit-remaining' ||
|
|
284
312
|
lower === 'x-ratelimit-remaining-requests' ||
|
|
@@ -289,7 +317,7 @@ export function extractRateLimit(headers) {
|
|
|
289
317
|
const n = Number(String(v));
|
|
290
318
|
if (Number.isFinite(n)) remaining = n;
|
|
291
319
|
} else if (
|
|
292
|
-
(lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
|
|
320
|
+
(lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining' || lower === 'openai-ratelimit-remaining-tokens') &&
|
|
293
321
|
remaining === undefined
|
|
294
322
|
) {
|
|
295
323
|
const n = Number(String(v));
|
|
@@ -306,15 +334,21 @@ export function extractRateLimit(headers) {
|
|
|
306
334
|
} else if (
|
|
307
335
|
lower === 'x-ratelimit-reset' ||
|
|
308
336
|
lower === 'x-ratelimit-reset-requests' ||
|
|
337
|
+
lower === 'x-ratelimit-reset-tokens' ||
|
|
309
338
|
lower === 'anthropic-ratelimit-requests-reset' ||
|
|
339
|
+
lower === 'anthropic-ratelimit-tokens-reset' ||
|
|
310
340
|
lower === 'ratelimit-reset'
|
|
311
341
|
) {
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
342
|
+
const dur = parseDurationToSeconds(v);
|
|
343
|
+
if (dur !== null) {
|
|
344
|
+
if (dur > 1000000000) {
|
|
345
|
+
const epochMs = dur > 100000000000 ? dur : dur * 1000;
|
|
346
|
+
reset = dur;
|
|
347
|
+
resetMs = Math.max(0, epochMs - Date.now());
|
|
348
|
+
} else {
|
|
349
|
+
reset = dur;
|
|
350
|
+
resetMs = Math.max(0, Math.round(dur * 1000));
|
|
351
|
+
}
|
|
318
352
|
}
|
|
319
353
|
}
|
|
320
354
|
}
|
|
@@ -347,9 +381,15 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
|
347
381
|
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
348
382
|
const list = refs.slice();
|
|
349
383
|
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
384
|
+
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
350
385
|
return list.sort((a, b) => {
|
|
351
386
|
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
352
387
|
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
388
|
+
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
389
|
+
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
390
|
+
const scoreA = ca / wa;
|
|
391
|
+
const scoreB = cb / wb;
|
|
392
|
+
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
353
393
|
return ca - cb;
|
|
354
394
|
});
|
|
355
395
|
}
|
|
@@ -489,3 +529,13 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
|
489
529
|
});
|
|
490
530
|
return ok === true;
|
|
491
531
|
}
|
|
532
|
+
|
|
533
|
+
/** Check if a key reference is permanently revoked due to auth/401 failure (#369). */
|
|
534
|
+
export function isKeyRevoked(pool, ref) {
|
|
535
|
+
if (!pool || !ref) return false;
|
|
536
|
+
if (pool.revokedRefs instanceof Set && pool.revokedRefs.has(ref)) return true;
|
|
537
|
+
if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
|
|
538
|
+
if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
|
|
539
|
+
if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
|
|
540
|
+
return false;
|
|
541
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
parseRetryAfter,
|
|
10
10
|
extractRateLimit,
|
|
11
11
|
isRateLimited,
|
|
12
|
+
isKeyPaused,
|
|
13
|
+
isKeyRevoked,
|
|
12
14
|
formatExhaustionMessage,
|
|
13
15
|
sortAttemptList,
|
|
14
16
|
} from './pool.js';
|
|
15
|
-
import { pickCascadeFallback } from './cascade.js';
|
|
17
|
+
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
16
18
|
import { nextQuotaReset } from './quota-window.js';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -52,20 +54,22 @@ export function createRotate(deps) {
|
|
|
52
54
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
53
55
|
setRotateStartMs(reqStore.startMs);
|
|
54
56
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
57
|
+
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
|
|
55
58
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
56
59
|
if (strategy !== 'round-robin') {
|
|
57
|
-
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
|
|
60
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
58
61
|
}
|
|
59
62
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
60
|
-
// #193: prefer least-loaded key within limit
|
|
63
|
+
// #193 / #365: prefer least-loaded key within limit with weights
|
|
61
64
|
const available = attemptList.filter((r) => {
|
|
65
|
+
if (isKeyPaused(pool, r)) return false;
|
|
62
66
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
63
67
|
if (fu > now()) return false;
|
|
64
68
|
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
65
69
|
if (exp !== undefined && now() >= exp) return false;
|
|
66
70
|
return true;
|
|
67
71
|
});
|
|
68
|
-
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
72
|
+
const preferred = concurrencyTracker.pickLeastLoaded(available, now(), pool.weightsMap);
|
|
69
73
|
if (preferred && attemptList[0] !== preferred) {
|
|
70
74
|
const list = attemptList.slice();
|
|
71
75
|
const i = list.indexOf(preferred);
|
|
@@ -106,13 +110,14 @@ export function createRotate(deps) {
|
|
|
106
110
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
107
111
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
108
112
|
const _cStr = String(errCode ?? '');
|
|
109
|
-
if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
|
|
113
|
+
if (_cStr === 'AUTH' || /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated/i.test(errMsg)) {
|
|
110
114
|
const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
|
|
111
115
|
pool.state.authFailCounts.set(targetRef, _c2);
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
|
|
117
|
+
pool.state.revokedRefs.add(targetRef);
|
|
118
|
+
pool.state.failedUntil.set(targetRef, Infinity);
|
|
119
|
+
pushEvent(pool, targetRef, 'KEY_REVOKED', 0);
|
|
120
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently REVOKED on auth failure`);
|
|
116
121
|
} else {
|
|
117
122
|
pool.state.authFailCounts.delete(targetRef);
|
|
118
123
|
}
|
|
@@ -240,12 +245,15 @@ export function createRotate(deps) {
|
|
|
240
245
|
cool = rate.retryAfter * 1000;
|
|
241
246
|
} else if (rate.reset && rate.reset > now()) {
|
|
242
247
|
cool = rate.reset - now();
|
|
248
|
+
} else if (rate.reset && rate.reset > 0 && rate.reset < 86400) {
|
|
249
|
+
cool = Math.round(rate.reset * 1000);
|
|
243
250
|
} else {
|
|
244
251
|
cool = pool.cooldownMs ?? cooldownMs;
|
|
245
252
|
}
|
|
246
253
|
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
247
254
|
const effCool = Math.min(cool, maxCool ?? cool);
|
|
248
255
|
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
256
|
+
pool.state.proactiveSwitches = (pool.state.proactiveSwitches ?? 0) + 1;
|
|
249
257
|
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
250
258
|
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
251
259
|
}
|
|
@@ -315,16 +323,25 @@ export function createRotate(deps) {
|
|
|
315
323
|
// notify via extracted helper (see notifyExhaustion above)
|
|
316
324
|
notifyExhaustion(runtime, pool, { provider: options.provider });
|
|
317
325
|
|
|
318
|
-
// #194: cross-provider cascade failover
|
|
319
|
-
|
|
326
|
+
// #194 / #364: cross-provider cascade failover with model mapping
|
|
327
|
+
const maxDepth = runtime.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
328
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
329
|
+
if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
320
330
|
const pools = runtime.providerToPool;
|
|
321
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
331
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
|
|
322
332
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
323
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
333
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
|
|
324
334
|
pool.state.lastReason = 'CASCADE';
|
|
325
335
|
pool.state.lastSwitchAt = now();
|
|
326
|
-
|
|
327
|
-
|
|
336
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
337
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
338
|
+
const innerCascade = rotate({
|
|
339
|
+
...options,
|
|
340
|
+
provider: fb.provider,
|
|
341
|
+
model: fb.model || options.model,
|
|
342
|
+
__isCascade: true,
|
|
343
|
+
__cascadeDepth: currentDepth + 1,
|
|
344
|
+
}, fb.pool);
|
|
328
345
|
for await (const chunk of innerCascade) {
|
|
329
346
|
yield chunk;
|
|
330
347
|
}
|
package/lib/routes-ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
1
2
|
// lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
|
|
2
3
|
// Thin orchestrator: each route family lives in its own lib/ops-*.js module.
|
|
3
4
|
// Registration is injected with the live apply() dependencies.
|
|
@@ -18,4 +19,5 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
18
19
|
registerKeyRoutes(ctx, deps);
|
|
19
20
|
registerTestRoutes(ctx, deps);
|
|
20
21
|
registerWebhookActionRoute(ctx, deps);
|
|
22
|
+
registerMetricsRoutes(ctx, deps);
|
|
21
23
|
}
|
package/lib/webhook.js
CHANGED
|
@@ -12,6 +12,8 @@ export function detectPlatform(url) {
|
|
|
12
12
|
if (u.includes('api.telegram.org')) return 'telegram';
|
|
13
13
|
if (u.includes('discord.com/api/webhooks')) return 'discord';
|
|
14
14
|
if (u.includes('hooks.slack.com')) return 'slack';
|
|
15
|
+
if (u.includes('ntfy.sh') || u.includes('/ntfy')) return 'ntfy';
|
|
16
|
+
if (u.includes('gotify') || u.includes('/message')) return 'gotify';
|
|
15
17
|
return 'generic';
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -66,6 +68,32 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
66
68
|
],
|
|
67
69
|
};
|
|
68
70
|
}
|
|
71
|
+
if (platform === 'gotify') {
|
|
72
|
+
return {
|
|
73
|
+
title,
|
|
74
|
+
message: text,
|
|
75
|
+
priority: 5,
|
|
76
|
+
extras: {
|
|
77
|
+
'client::notification': {
|
|
78
|
+
click: { url: actions[0]?.url || '' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (platform === 'ntfy') {
|
|
84
|
+
return {
|
|
85
|
+
topic: url.split('/').pop(),
|
|
86
|
+
title,
|
|
87
|
+
message: text,
|
|
88
|
+
priority: 4,
|
|
89
|
+
tags: ['warning', 'key-rotation'],
|
|
90
|
+
actions: actions.map((a) => ({
|
|
91
|
+
action: 'view',
|
|
92
|
+
label: a.label,
|
|
93
|
+
url: a.url || '',
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
69
97
|
// generic JSON webhook: actions as plain data, receiver decides
|
|
70
98
|
return { ...payload, actions };
|
|
71
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.28",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|