@goodandready/dsh-key-rotation 0.8.25 → 0.8.27
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/cascade.js +14 -6
- package/lib/client.js +167 -6
- package/lib/concurrency.js +6 -4
- package/lib/index.js +4 -4
- package/lib/pool-builder.js +13 -0
- package/lib/pool.js +57 -17
- package/lib/rotate.js +25 -10
- package/package.json +1 -1
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)) 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,16 @@ 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
|
+
pauseKey: 'Pause key from rotation',
|
|
88
|
+
resumeKey: 'Resume key rotation',
|
|
89
|
+
sparklineTraffic: 'Traffic Trend',
|
|
90
|
+
sparklineLatency: 'Latency Trend',
|
|
81
91
|
addProvider: '+ Add provider',
|
|
82
92
|
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
93
|
cooldown: 'Cooldown after failure (ms)',
|
|
@@ -223,6 +233,16 @@ window.__ModuleLoader__.load({
|
|
|
223
233
|
addKey: '+ 添加密钥',
|
|
224
234
|
addKeyTitle: '添加 API 密钥',
|
|
225
235
|
noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
|
|
236
|
+
bulkImport: '批量导入',
|
|
237
|
+
bulkImportDesc: '按行粘贴 API 密钥,支持 .env (KEY=value) 或 CSV (KEY, 权重) 格式:',
|
|
238
|
+
bulkImportBtn: '导入密钥',
|
|
239
|
+
bulkImportCancel: '取消',
|
|
240
|
+
bulkImportSuccess: '已导入 {n} 个密钥',
|
|
241
|
+
keyPaused: '已暂停',
|
|
242
|
+
pauseKey: '暂停此密钥轮换',
|
|
243
|
+
resumeKey: '恢复此密钥轮换',
|
|
244
|
+
sparklineTraffic: '流量趋势',
|
|
245
|
+
sparklineLatency: '延迟趋势',
|
|
226
246
|
addProvider: '+ 添加提供商',
|
|
227
247
|
desc: '按提供商的 API 密钥轮换。为每个提供商列出其 API 密钥(环境变量名,存储在 DSH 凭据中)。插件按顺序通过提供商的密钥路由模型,并在配额/速率限制失败时切换到下一个。',
|
|
228
248
|
cooldown: '故障后冷却时间 (毫秒)',
|
|
@@ -590,6 +610,74 @@ window.__ModuleLoader__.load({
|
|
|
590
610
|
};
|
|
591
611
|
}
|
|
592
612
|
|
|
613
|
+
|
|
614
|
+
function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, #3b82f6)' }) {
|
|
615
|
+
if (!Array.isArray(points) || points.length < 2) {
|
|
616
|
+
return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
|
|
617
|
+
h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
const min = Math.min(...points);
|
|
621
|
+
const max = Math.max(...points);
|
|
622
|
+
const range = max - min || 1;
|
|
623
|
+
const step = width / (points.length - 1);
|
|
624
|
+
const coords = points.map((p, i) => {
|
|
625
|
+
const x = Math.round(i * step * 10) / 10;
|
|
626
|
+
const y = Math.round((height - ((p - min) / range) * (height - 4) - 2) * 10) / 10;
|
|
627
|
+
return `${x},${y}`;
|
|
628
|
+
}).join(' ');
|
|
629
|
+
|
|
630
|
+
return h('svg', {
|
|
631
|
+
width,
|
|
632
|
+
height,
|
|
633
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
634
|
+
style: { display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' },
|
|
635
|
+
}, h('polyline', {
|
|
636
|
+
points: coords,
|
|
637
|
+
fill: 'none',
|
|
638
|
+
stroke,
|
|
639
|
+
strokeWidth: 2,
|
|
640
|
+
strokeLinecap: 'round',
|
|
641
|
+
strokeLinejoin: 'round',
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function parseBulkKeys(text) {
|
|
646
|
+
if (!text || typeof text !== 'string') return [];
|
|
647
|
+
const lines = text.split(/\r?\n/);
|
|
648
|
+
const result = [];
|
|
649
|
+
const seen = new Set();
|
|
650
|
+
for (let raw of lines) {
|
|
651
|
+
const line = raw.trim();
|
|
652
|
+
if (!line || line.startsWith('#') || line.startsWith('//')) continue;
|
|
653
|
+
const envMatch = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
654
|
+
if (envMatch) {
|
|
655
|
+
const ref = envMatch[1];
|
|
656
|
+
if (!seen.has(ref)) {
|
|
657
|
+
seen.add(ref);
|
|
658
|
+
let weight = 1;
|
|
659
|
+
const rest = envMatch[2].trim().replace(/^['"]|['"]$/g, '');
|
|
660
|
+
const wMatch = rest.match(/,\s*(?:weight\s*=\s*)?([0-9]+)$/i);
|
|
661
|
+
if (wMatch) weight = Math.max(1, parseInt(wMatch[1], 10) || 1);
|
|
662
|
+
result.push({ ref, weight });
|
|
663
|
+
}
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const parts = line.split(',');
|
|
667
|
+
const ref = parts[0].trim();
|
|
668
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref) && !seen.has(ref)) {
|
|
669
|
+
seen.add(ref);
|
|
670
|
+
let weight = 1;
|
|
671
|
+
if (parts[1]) {
|
|
672
|
+
const wm = parts[1].trim().match(/(?:weight\s*=\s*)?([0-9]+)/i);
|
|
673
|
+
if (wm) weight = Math.max(1, parseInt(wm[1], 10) || 1);
|
|
674
|
+
}
|
|
675
|
+
result.push({ ref, weight });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
593
681
|
function UpdaterSection({ t }) {
|
|
594
682
|
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
595
683
|
const load = React.useCallback(async () => {
|
|
@@ -883,6 +971,47 @@ window.__ModuleLoader__.load({
|
|
|
883
971
|
const [selected, setSelected] = React.useState(new Set());
|
|
884
972
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
885
973
|
const [undo, setUndo] = React.useState(null);
|
|
974
|
+
const [bulkOpen, setBulkOpen] = React.useState({});
|
|
975
|
+
const [bulkText, setBulkText] = React.useState({});
|
|
976
|
+
|
|
977
|
+
const togglePauseKey = (pIndex, kIndex) => setField((cur) => {
|
|
978
|
+
const providers = [...(cur.providers ?? [])];
|
|
979
|
+
const ent = { ...providers[pIndex] };
|
|
980
|
+
const paused = [...(ent.paused ?? [])];
|
|
981
|
+
paused[kIndex] = !paused[kIndex];
|
|
982
|
+
ent.paused = paused;
|
|
983
|
+
providers[pIndex] = ent;
|
|
984
|
+
return { ...cur, providers };
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
const applyBulkImport = (pIndex) => {
|
|
988
|
+
const text = bulkText[pIndex] || '';
|
|
989
|
+
const parsed = parseBulkKeys(text);
|
|
990
|
+
if (!parsed.length) return;
|
|
991
|
+
setField((cur) => {
|
|
992
|
+
const providers = [...(cur.providers ?? [])];
|
|
993
|
+
const ent = { ...providers[pIndex] };
|
|
994
|
+
const existingKeys = new Set(ent.keys ?? []);
|
|
995
|
+
const nextKeys = [...(ent.keys ?? [])];
|
|
996
|
+
const nextWeights = [...(ent.weights ?? [])];
|
|
997
|
+
const nextPaused = [...(ent.paused ?? [])];
|
|
998
|
+
for (const item of parsed) {
|
|
999
|
+
if (!existingKeys.has(item.ref)) {
|
|
1000
|
+
existingKeys.add(item.ref);
|
|
1001
|
+
nextKeys.push(item.ref);
|
|
1002
|
+
nextWeights.push(item.weight);
|
|
1003
|
+
nextPaused.push(false);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
ent.keys = nextKeys;
|
|
1007
|
+
ent.weights = nextWeights;
|
|
1008
|
+
ent.paused = nextPaused;
|
|
1009
|
+
providers[pIndex] = ent;
|
|
1010
|
+
return { ...cur, providers };
|
|
1011
|
+
});
|
|
1012
|
+
setBulkText((cur) => ({ ...cur, [pIndex]: '' }));
|
|
1013
|
+
setBulkOpen((cur) => ({ ...cur, [pIndex]: false }));
|
|
1014
|
+
};
|
|
886
1015
|
const undoTimer = React.useRef(null);
|
|
887
1016
|
React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
|
|
888
1017
|
// #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
|
|
@@ -1451,7 +1580,8 @@ window.__ModuleLoader__.load({
|
|
|
1451
1580
|
|
|
1452
1581
|
// Key state dot: color and label read at a glance; "key not found"
|
|
1453
1582
|
// catches an env-name typo that would otherwise stay silent.
|
|
1454
|
-
const keyStatus = (providerId, ref) => {
|
|
1583
|
+
const keyStatus = (providerId, ref, isPaused = false) => {
|
|
1584
|
+
if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused') };
|
|
1455
1585
|
const hit = keyInfo(providerId, ref);
|
|
1456
1586
|
if (!hit) return null;
|
|
1457
1587
|
if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
|
|
@@ -1484,10 +1614,12 @@ window.__ModuleLoader__.load({
|
|
|
1484
1614
|
|
|
1485
1615
|
const keys = entry.keys ?? [];
|
|
1486
1616
|
const entryWeights = entry.weights ?? [];
|
|
1617
|
+
const entryPaused = entry.paused ?? [];
|
|
1487
1618
|
|
|
1488
1619
|
let readyCount = 0, cooldownCount = 0, errorCount = 0;
|
|
1489
|
-
for (
|
|
1490
|
-
const
|
|
1620
|
+
for (let idx = 0; idx < keys.length; idx++) {
|
|
1621
|
+
const k = keys[idx];
|
|
1622
|
+
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1491
1623
|
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
1492
1624
|
const tr = testResult[k];
|
|
1493
1625
|
if (tr && !tr.ok) errorCount++;
|
|
@@ -1495,9 +1627,9 @@ window.__ModuleLoader__.load({
|
|
|
1495
1627
|
else readyCount++;
|
|
1496
1628
|
}
|
|
1497
1629
|
|
|
1498
|
-
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k }) => {
|
|
1630
|
+
const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
|
|
1499
1631
|
if (statusFilter === 'all') return true;
|
|
1500
|
-
const st = keyStatus(entry.provider, k);
|
|
1632
|
+
const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
|
|
1501
1633
|
const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
|
|
1502
1634
|
const tr = testResult[k];
|
|
1503
1635
|
if (statusFilter === 'ready') return !isCool;
|
|
@@ -1514,7 +1646,8 @@ window.__ModuleLoader__.load({
|
|
|
1514
1646
|
) : null;
|
|
1515
1647
|
|
|
1516
1648
|
const keyRows = filteredIndices.map(({ key, kIndex }) => {
|
|
1517
|
-
const
|
|
1649
|
+
const isP = Boolean(entryPaused[kIndex]);
|
|
1650
|
+
const st = keyStatus(entry.provider, key, isP);
|
|
1518
1651
|
const info = keyInfo(entry.provider, key);
|
|
1519
1652
|
const rowKey = entry.provider + '/' + key;
|
|
1520
1653
|
const typed = secretDraft[rowKey];
|
|
@@ -1570,6 +1703,14 @@ window.__ModuleLoader__.load({
|
|
|
1570
1703
|
title: t('weightHint'),
|
|
1571
1704
|
onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
|
|
1572
1705
|
style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
|
|
1706
|
+
meta.push(h('button', {
|
|
1707
|
+
key: 'pause-btn',
|
|
1708
|
+
type: 'button',
|
|
1709
|
+
className: 'krot-btn' + (isP ? ' krot-pill-warn' : ''),
|
|
1710
|
+
title: isP ? t('resumeKey') : t('pauseKey'),
|
|
1711
|
+
style: { padding: '2px 6px', fontSize: '11px' },
|
|
1712
|
+
onClick: () => togglePauseKey(pIndex, kIndex),
|
|
1713
|
+
}, isP ? '⏸ ' + t('keyPaused') : '▶'));
|
|
1573
1714
|
// #210: RPM capacity indicator (only when rpmLimit is active)
|
|
1574
1715
|
if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
|
|
1575
1716
|
title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
|
|
@@ -1701,6 +1842,10 @@ window.__ModuleLoader__.load({
|
|
|
1701
1842
|
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('keyLabel').replace('{n}', String(x.index + 1)) + ': ' + x.usage + ' (' + pct + '%)')
|
|
1702
1843
|
);
|
|
1703
1844
|
})
|
|
1845
|
+
) : null,
|
|
1846
|
+
total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1847
|
+
h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
|
|
1848
|
+
h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
|
|
1704
1849
|
) : null
|
|
1705
1850
|
);
|
|
1706
1851
|
})();
|
|
@@ -1773,6 +1918,22 @@ window.__ModuleLoader__.load({
|
|
|
1773
1918
|
h(UpdaterSection, { t }),
|
|
1774
1919
|
h('div', { className: 'krot-foot' },
|
|
1775
1920
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
|
|
1921
|
+
btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
|
|
1922
|
+
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' } },
|
|
1923
|
+
h('p', { style: { margin: '0 0 6px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('bulkImportDesc')),
|
|
1924
|
+
h('textarea', {
|
|
1925
|
+
className: 'krot-in',
|
|
1926
|
+
rows: 4,
|
|
1927
|
+
style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical' },
|
|
1928
|
+
placeholder: 'KEY_1\nKEY_2, weight=5\nOPENAI_KEY=sk-...',
|
|
1929
|
+
value: bulkText[pIndex] || '',
|
|
1930
|
+
onChange: (e) => setBulkText(cur => ({ ...cur, [pIndex]: e.target.value })),
|
|
1931
|
+
}),
|
|
1932
|
+
h('div', { style: { display: 'flex', gap: '8px', marginTop: '6px' } },
|
|
1933
|
+
btn(t('bulkImportBtn'), () => applyBulkImport(pIndex), { primary: true }),
|
|
1934
|
+
btn(t('bulkImportCancel'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: false })))
|
|
1935
|
+
)
|
|
1936
|
+
) : null,
|
|
1776
1937
|
switchesLine,
|
|
1777
1938
|
budgetLine,
|
|
1778
1939
|
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;
|
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);
|
package/lib/pool-builder.js
CHANGED
|
@@ -15,6 +15,7 @@ export function buildPoolItem({
|
|
|
15
15
|
base,
|
|
16
16
|
keys,
|
|
17
17
|
weights,
|
|
18
|
+
paused,
|
|
18
19
|
poolCooldown,
|
|
19
20
|
poolMax,
|
|
20
21
|
expiresAt,
|
|
@@ -44,11 +45,23 @@ export function buildPoolItem({
|
|
|
44
45
|
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
const parsedPaused = new Set();
|
|
49
|
+
if (Array.isArray(paused)) {
|
|
50
|
+
for (let i = 0; i < refs.length; i++) {
|
|
51
|
+
if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const weightsMap = {};
|
|
55
|
+
for (let i = 0; i < refs.length; i++) {
|
|
56
|
+
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
57
|
+
}
|
|
47
58
|
return {
|
|
48
59
|
base,
|
|
49
60
|
refs,
|
|
50
61
|
weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
62
|
+
weightsMap,
|
|
51
63
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
64
|
+
pausedRefs: parsedPaused,
|
|
52
65
|
state: makeState(base),
|
|
53
66
|
cooldownMs: poolCooldown,
|
|
54
67
|
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
|
}
|
package/lib/rotate.js
CHANGED
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
parseRetryAfter,
|
|
10
10
|
extractRateLimit,
|
|
11
11
|
isRateLimited,
|
|
12
|
+
isKeyPaused,
|
|
12
13
|
formatExhaustionMessage,
|
|
13
14
|
sortAttemptList,
|
|
14
15
|
} from './pool.js';
|
|
15
|
-
import { pickCascadeFallback } from './cascade.js';
|
|
16
|
+
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
16
17
|
import { nextQuotaReset } from './quota-window.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -52,20 +53,22 @@ export function createRotate(deps) {
|
|
|
52
53
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
53
54
|
setRotateStartMs(reqStore.startMs);
|
|
54
55
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
56
|
+
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r));
|
|
55
57
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
56
58
|
if (strategy !== 'round-robin') {
|
|
57
|
-
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
|
|
59
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
58
60
|
}
|
|
59
61
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
60
|
-
// #193: prefer least-loaded key within limit
|
|
62
|
+
// #193 / #365: prefer least-loaded key within limit with weights
|
|
61
63
|
const available = attemptList.filter((r) => {
|
|
64
|
+
if (isKeyPaused(pool, r)) return false;
|
|
62
65
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
63
66
|
if (fu > now()) return false;
|
|
64
67
|
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
65
68
|
if (exp !== undefined && now() >= exp) return false;
|
|
66
69
|
return true;
|
|
67
70
|
});
|
|
68
|
-
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
71
|
+
const preferred = concurrencyTracker.pickLeastLoaded(available, now(), pool.weightsMap);
|
|
69
72
|
if (preferred && attemptList[0] !== preferred) {
|
|
70
73
|
const list = attemptList.slice();
|
|
71
74
|
const i = list.indexOf(preferred);
|
|
@@ -240,12 +243,15 @@ export function createRotate(deps) {
|
|
|
240
243
|
cool = rate.retryAfter * 1000;
|
|
241
244
|
} else if (rate.reset && rate.reset > now()) {
|
|
242
245
|
cool = rate.reset - now();
|
|
246
|
+
} else if (rate.reset && rate.reset > 0 && rate.reset < 86400) {
|
|
247
|
+
cool = Math.round(rate.reset * 1000);
|
|
243
248
|
} else {
|
|
244
249
|
cool = pool.cooldownMs ?? cooldownMs;
|
|
245
250
|
}
|
|
246
251
|
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
247
252
|
const effCool = Math.min(cool, maxCool ?? cool);
|
|
248
253
|
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
254
|
+
pool.state.proactiveSwitches = (pool.state.proactiveSwitches ?? 0) + 1;
|
|
249
255
|
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
250
256
|
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
257
|
}
|
|
@@ -315,16 +321,25 @@ export function createRotate(deps) {
|
|
|
315
321
|
// notify via extracted helper (see notifyExhaustion above)
|
|
316
322
|
notifyExhaustion(runtime, pool, { provider: options.provider });
|
|
317
323
|
|
|
318
|
-
// #194: cross-provider cascade failover
|
|
319
|
-
|
|
324
|
+
// #194 / #364: cross-provider cascade failover with model mapping
|
|
325
|
+
const maxDepth = runtime.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
326
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
327
|
+
if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
320
328
|
const pools = runtime.providerToPool;
|
|
321
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
329
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
|
|
322
330
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
323
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
331
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
|
|
324
332
|
pool.state.lastReason = 'CASCADE';
|
|
325
333
|
pool.state.lastSwitchAt = now();
|
|
326
|
-
|
|
327
|
-
|
|
334
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
335
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
336
|
+
const innerCascade = rotate({
|
|
337
|
+
...options,
|
|
338
|
+
provider: fb.provider,
|
|
339
|
+
model: fb.model || options.model,
|
|
340
|
+
__isCascade: true,
|
|
341
|
+
__cascadeDepth: currentDepth + 1,
|
|
342
|
+
}, fb.pool);
|
|
328
343
|
for await (const chunk of innerCascade) {
|
|
329
344
|
yield chunk;
|
|
330
345
|
}
|
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.27",
|
|
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": [
|