@goodandready/dsh-key-rotation 0.8.24 → 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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
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.25 - 2026-09-27
7
+
8
+ ### Fixed & Hardened
9
+ - **Sandbox cache HTTP method validation (#357)**:
10
+ - `lib/ops-test.js`: enforced strict `GET` method check on `/dsh-key-rotation/sandbox-cache`. Non-GET requests (`POST`, `PUT`, `DELETE`) now consistently return HTTP 405 Method Not Allowed (`{ error: { code: 'method', message: 'GET only' } }`).
11
+ - **Webhook action security and timeout hardening (#358)**:
12
+ - `lib/ops-webhook.js`: converted authorization bearer token validation to constant-time comparison via `crypto.timingSafeEqual`, preventing timing side-channel attacks on `webhookActionToken`.
13
+ - Added strict Telegram bot token format validation (`TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/`).
14
+ - Added 10-second timeout guard (`signal: AbortSignal.timeout(10000)`) on outgoing Telegram API `setWebhook` requests to eliminate socket hang risks.
15
+ - **Dead code removal & unexported private symbols (#359)**:
16
+ - Removed unimported orphaned module `lib/bounded-map.js` and removed its standalone test cases.
17
+ - Eliminated dead exports in internal modules: unexported `CONCURRENCY_DEFAULT_LIMIT`, `CONCURRENCY_STALE_LOCK_MS` in `lib/concurrency.js`, `MAX_USAGE_RETENTION_DAYS` in `lib/usage-report.js`, `WEBHOOK_*` constants in `lib/webhook.js`, `assertJson` and `SECRET_FIELDS` in `lib/settings-write.js`, and `isNewerVersion` in `lib/plugin-updater.js`.
18
+ - **Documentation contract alignment (#360)**:
19
+ - `docs/design/DESIGN.md`: added mandatory sections «Политика маршрутов» (complete 12-route method, origin, and authorization matrix) and «Что публикуется» (npm files, exclusions, and verification timestamp) per `dsh-documentation-standard`.
20
+ - Corrected webhook endpoint URI in DESIGN.md to `/dsh-key-rotation/webhook-action`.
21
+ - **Canonical scoped package name in module export (#361)**:
22
+ - `lib/index.js`: unified `export const name = '@goodandready/dsh-key-rotation';` across all four package identity checkpoints (`package.json`, `cordis.patch.yml`, `lib/client.js`, and `lib/index.js`), resolving preflight check failure.
23
+
6
24
  ## 0.8.24 - 2026-09-25
7
25
 
8
26
  ### Security Hardening
package/lib/cascade.js CHANGED
@@ -1,9 +1,16 @@
1
- // cascade.js — cross-provider failover cascade (issue #194).
2
- // ponytail: minimal — pick fallback provider from a RegionMap-like config.
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 = 1;
4
+ export const CASCADE_MAX_DEPTH = 3;
5
5
 
6
- export function pickCascadeFallback(provider, cfg, pools) {
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
- return { provider: fb.provider, pool, model: fb.model || null };
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 (const k of keys) {
1490
- const st = keyStatus(entry.provider, k);
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 st = keyStatus(entry.provider, key);
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,
@@ -1,7 +1,7 @@
1
1
  // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
2
 
3
- export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
3
+ const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
5
 
6
6
  export class ConcurrencyTracker {
7
7
  constructor(opts) {
@@ -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 bestCount = Infinity;
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
- if (count < bestCount) {
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
- bestCount = count;
72
+ bestScore = score;
71
73
  }
72
74
  }
73
75
  return best;
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
37
37
  import { buildPoolItem, cleanupRemovedProviders } from './pool-builder.js';
38
38
  import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
39
39
 
40
- export const name = 'dsh-key-rotation';
40
+ export const name = '@goodandready/dsh-key-rotation';
41
41
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
42
42
  export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
43
43
  export { notifySwitch, notifyExhaustion };
@@ -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/ops-test.js CHANGED
@@ -90,6 +90,7 @@ ctx.effect(() => ctx.webServer.register({
90
90
  kind: 'exact',
91
91
  path: SANDBOX_CACHE_PATH,
92
92
  handler: (req, res) => {
93
+ if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
93
94
  if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
94
95
  const cacheSnap = typeof lastTestCache?.snapshot === 'function'
95
96
  ? lastTestCache.snapshot()
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -26,7 +27,11 @@ ctx.effect(() => ctx.webServer.register({
26
27
  const expected = runtime.webhookActionToken;
27
28
  if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
29
  const auth = String(req.headers.authorization ?? '');
29
- if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
+ const expectedAuth = `Bearer ${expected}`;
31
+ const authBuf = Buffer.from(auth);
32
+ const expBuf = Buffer.from(expectedAuth);
33
+ const tokenMatch = authBuf.length === expBuf.length && crypto.timingSafeEqual(authBuf, expBuf);
34
+ if (!tokenMatch) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
35
  let body;
31
36
  try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
37
  // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
@@ -45,12 +50,18 @@ ctx.effect(() => ctx.webServer.register({
45
50
  if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
51
  const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
52
  if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
53
+ const TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/;
54
+ if (!TELEGRAM_BOT_TOKEN_RE.test(botToken)) {
55
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
56
+ return;
57
+ }
48
58
  // derive the public URL from request headers; explicit URL wins
49
59
  const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
60
  try {
51
61
  const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
62
  method: 'POST', headers: { 'content-type': 'application/json' },
53
63
  body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
64
+ signal: AbortSignal.timeout(10000),
54
65
  });
55
66
  const hookData = await hookRes.json().catch(() => ({}));
56
67
  json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
@@ -135,7 +135,7 @@ function comparePrerelease(left, right) {
135
135
  return 0
136
136
  }
137
137
 
138
- export function isNewerVersion(currentValue, candidateValue) {
138
+ function isNewerVersion(currentValue, candidateValue) {
139
139
  const current = parseSemver(currentValue)
140
140
  const candidate = parseSemver(candidateValue)
141
141
  if (current === undefined || candidate === undefined) return false
@@ -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 num = Number(v);
274
- if (Number.isFinite(num) && num >= 0) {
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 n = Number(String(v));
313
- if (Number.isFinite(n)) {
314
- reset = n;
315
- } else {
316
- const parsed = Date.parse(v);
317
- if (!Number.isNaN(parsed)) reset = parsed;
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 (guarded against infinite recursion)
319
- if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
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
- // Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
327
- const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
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
  }
@@ -1,6 +1,6 @@
1
1
  // Settings writes from redacted browser views must never replace an entire
2
2
  // namespace. Host-side mutate is atomic and rejects conflicts on both core eras.
3
- export const SECRET_FIELDS = new Set(['webhookActionToken']);
3
+ const SECRET_FIELDS = new Set(['webhookActionToken']);
4
4
  const UNSAFE_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
5
5
 
6
6
  export function plainObject(value) {
@@ -9,7 +9,7 @@ export function plainObject(value) {
9
9
  return proto === Object.prototype || proto === null;
10
10
  }
11
11
 
12
- export function assertJson(value, depth = 0) {
12
+ function assertJson(value, depth = 0) {
13
13
  if (depth > 40) throw new TypeError('Configuration is nested too deeply');
14
14
  if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
15
15
  if (typeof value === 'number' && Number.isFinite(value)) return;
@@ -1,6 +1,6 @@
1
1
  // lib/usage-report.js - #209 usage report rows from pool state + 30-day compaction (#11). Pure helpers.
2
2
  const DAY_MS = 86400000;
3
- export const MAX_USAGE_RETENTION_DAYS = 30;
3
+ const MAX_USAGE_RETENTION_DAYS = 30;
4
4
 
5
5
  /**
6
6
  * Build per-key usage rows for the last `days` ISO days (default 7).
package/lib/webhook.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { bestEffort } from './best-effort.js';
2
2
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
3
- export const WEBHOOK_TIMEOUT_MS = 5000;
4
- export const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
- export const WEBHOOK_RETRY_DELAY_MS = 2000;
3
+ const WEBHOOK_TIMEOUT_MS = 5000;
4
+ const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
+ const WEBHOOK_RETRY_DELAY_MS = 2000;
6
6
 
7
7
  /**
8
8
  * Detect platform from webhook URL (#199).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.24",
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": [
@@ -1,65 +0,0 @@
1
- // lib/bounded-map.js — Map with max size + optional TTL (#262).
2
-
3
- export class BoundedMap {
4
- /** @param {{ max?: number, ttlMs?: number|null }} opts */
5
- constructor({ max = 1000, ttlMs = null } = {}) {
6
- if (!Number.isFinite(max) || max < 1) throw new Error('BoundedMap: max must be >= 1');
7
- this.max = max;
8
- this.ttlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : null;
9
- this._m = new Map(); // insertion order = LRU order when we re-insert on get/set
10
- }
11
-
12
- get size() { return this._m.size; }
13
-
14
- _expired(entry, now) {
15
- if (!this.ttlMs || !entry) return false;
16
- return now - entry.at >= this.ttlMs;
17
- }
18
-
19
- get(key, now = Date.now()) {
20
- const e = this._m.get(key);
21
- if (!e) return undefined;
22
- if (this._expired(e, now)) {
23
- this._m.delete(key);
24
- return undefined;
25
- }
26
- // refresh LRU
27
- this._m.delete(key);
28
- this._m.set(key, e);
29
- return e.v;
30
- }
31
-
32
- set(key, value, now = Date.now()) {
33
- if (this._m.has(key)) this._m.delete(key);
34
- this._m.set(key, { v: value, at: now });
35
- while (this._m.size > this.max) {
36
- const oldest = this._m.keys().next().value;
37
- this._m.delete(oldest);
38
- }
39
- return this;
40
- }
41
-
42
- has(key, now = Date.now()) {
43
- return this.get(key, now) !== undefined;
44
- }
45
-
46
- delete(key) { return this._m.delete(key); }
47
- clear() { this._m.clear(); }
48
-
49
- *entries(now = Date.now()) {
50
- for (const [k, e] of [...this._m.entries()]) {
51
- if (this._expired(e, now)) { this._m.delete(k); continue; }
52
- yield [k, e.v];
53
- }
54
- }
55
-
56
- *keys(now = Date.now()) {
57
- for (const [k] of this.entries(now)) yield k;
58
- }
59
-
60
- toObject(now = Date.now()) {
61
- const out = {};
62
- for (const [k, v] of this.entries(now)) out[k] = v;
63
- return out;
64
- }
65
- }