@goodandready/dsh-key-rotation 0.8.73 → 0.8.75
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 +23 -0
- package/lib/atomic-io.js +47 -4
- package/lib/client.js +178 -0
- package/lib/clone-lifecycle.js +5 -5
- package/lib/concurrency.js +25 -5
- package/lib/index.js +19 -4
- package/lib/key-drain.js +76 -41
- package/lib/lifecycle.js +4 -2
- package/lib/ops-config-io.js +40 -15
- package/lib/ops-keys.js +6 -5
- package/lib/ops-quick-action.js +6 -5
- package/lib/ops-telemetry.js +51 -16
- package/lib/pool-builder.js +16 -1
- package/lib/pool.js +10 -4
- package/lib/request-budget.js +8 -1
- package/lib/resolver.js +1 -1
- package/lib/rotate.js +628 -595
- package/lib/safe-fetch.js +30 -28
- package/lib/selection-trace.js +32 -11
- package/lib/simulator.js +8 -2
- package/lib/traffic-smoother.js +31 -20
- package/package.json +9 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.75 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Dependencies, Cleanup & Audit Finalization (Block 3 Release)
|
|
6
|
+
- **Zero Runtime Dependencies & Security Hardening (Issues #520, #535)**: Completely removed `undici` dependency and eliminated 5 high/moderate vulnerabilities (`pnpm audit --prod` reports 0 vulnerabilities); `safe-fetch.js` rewritten using standard `globalThis.fetch` and pure Node.js DNS/IP validation.
|
|
7
|
+
- **Atomic I/O Cleanup (Issue #521)**: Crash-safe file writes now safely clean up `.tmp` files on write/sync/rename errors, and `cleanupStaleTmpFiles` purges abandoned temporary files on startup.
|
|
8
|
+
- **Request Budget Inheritance & Signal Management (Issue #464)**: `maxAttempts` and `maxDurationMs` properly propagated from settings to `buildRuntime()` and `pool-builder`; `createRequestBudget` removes caller `AbortSignal` listeners upon `dispose()`; guaranteed cleanup in `rotate.js` `finally` block; traffic smoother now receives `budget.signal`.
|
|
9
|
+
- **Selection Trace Diagnosability (Issue #461)**: Candidate evaluation now logs detailed `SKIPPED` steps (`PAUSED`, `REVOKED`, `BROKEN`, `DRAINING`, `COOLDOWN`, `CAPABILITY_MISMATCH`, `QUOTA_EXHAUSTED`); added interactive Selection Trace UI inspector in settings card.
|
|
10
|
+
- **Managed Key Drain UI & Concurrency Robustness (Issue #463)**: Added interactive Managed Key Drain section in settings card; enhanced `ConcurrencyTracker` with `touch()` and prolonged 30m stale threshold for long-running streaming generation.
|
|
11
|
+
- **Offline Simulator Parity (Issue #462)**: Audit verification finalized following Block 2 quarantine cloning, Map lookup, and per-Context isolation fixes.
|
|
12
|
+
|
|
13
|
+
## 0.8.74 (2026-10-30)
|
|
14
|
+
|
|
15
|
+
### Rotation & Routing Reliability (Block 2 Release)
|
|
16
|
+
- **Error Status Normalization (Issue #545)**: `isSwitchableError` now normalizes string and numeric `code: "429"` / `429` to HTTP 429 when `status` is absent, ensuring default `switchCodes` trigger key rotation.
|
|
17
|
+
- **Quick Action Skip (Issue #540)**: Quick action skip now atomically synchronizes both `targetPool.state.pointer` and `cursor`, ensuring resolver round-robin picks the newly advanced key immediately.
|
|
18
|
+
- **Tiers & Tier-Priority Routing (Issue #541)**: Propagate `p.tiers` to `pool-builder` options, fix `typeof tiers === 'object'` syntax error, and add `tier-priority` strategy to Schemastery and resolver.
|
|
19
|
+
- **Traffic Smoothing Leaky Bucket (Issue #538)**: Implemented Generic Cell Rate Algorithm (GCRA) in `TrafficSmoother` to atomically reserve micro-queue slots ahead of sleep, preventing two concurrent requests from claiming the same time slot.
|
|
20
|
+
- **Simulator Quota & Broken Key Parity (Issue #534)**: `simulateRouting` now clones `brokenUntil`, `tokenUsage`, and `quotaWindows` into simulator pool state, properly filtering out unavailable keys.
|
|
21
|
+
- **Simulator Provider Map Lookup (Issue #536)**: Corrected `runtime.providerToPool` Map resolution in `ops-telemetry` simulator route, returning 404 for unrecognized providers.
|
|
22
|
+
- **Config Import Concurrency (Issue #553)**: Added optimistic revision check via `expectedRevision` in `handleConfigImport`, returning 409 Conflict upon concurrent modification.
|
|
23
|
+
- **Config Import Fail-Closed (Issue #533)**: `handleConfigImport` fails closed with 503 Service Unavailable when `settings.replace` is unavailable, preventing false-positive `{ applied: true }`.
|
|
24
|
+
- **Instance & Context Isolation (Issue #544)**: Introduced `KeyDrainManager`, `SelectionTraceStore`, and `DiagBuffer` classes with per-context lifecycle binding; disposal of one plugin instance cleanly purges only its own draining keys and telemetry without corrupting peer instances.
|
|
25
|
+
|
|
3
26
|
## 0.8.73 (2026-10-30)
|
|
4
27
|
|
|
5
28
|
### Security & Hardening (Block 1 Security Release)
|
package/lib/atomic-io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264).
|
|
1
|
+
// lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264, #521).
|
|
2
2
|
// Never overwrite a non-empty destination with empty/corrupt parse results.
|
|
3
|
+
// Clean up orphaned temporary files on failure or during startup.
|
|
3
4
|
|
|
4
5
|
import { promises as fs } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -8,6 +9,7 @@ import crypto from 'node:crypto';
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Atomically write data to filePath (temp + rename in same directory).
|
|
12
|
+
* Cleans up temporary file if open, write, sync, or rename fails (#521).
|
|
11
13
|
* @param {string} filePath
|
|
12
14
|
* @param {string|Buffer} data
|
|
13
15
|
*/
|
|
@@ -15,14 +17,55 @@ export async function atomicWriteFile(filePath, data) {
|
|
|
15
17
|
const dir = path.dirname(filePath);
|
|
16
18
|
await fs.mkdir(dir, { recursive: true });
|
|
17
19
|
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
18
|
-
|
|
20
|
+
let fh = null;
|
|
19
21
|
try {
|
|
22
|
+
fh = await fs.open(tmp, 'w');
|
|
20
23
|
await fh.writeFile(data);
|
|
21
24
|
await fh.sync();
|
|
22
|
-
} finally {
|
|
23
25
|
await fh.close();
|
|
26
|
+
fh = null;
|
|
27
|
+
await fs.rename(tmp, filePath);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (fh) {
|
|
30
|
+
try { await fh.close(); } catch {}
|
|
31
|
+
}
|
|
32
|
+
try { await fs.unlink(tmp); } catch {}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clean up stale temporary files left behind by crashed or interrupted writes (#521).
|
|
39
|
+
* @param {string} filePath
|
|
40
|
+
* @param {number} [maxAgeMs=3600000]
|
|
41
|
+
* @returns {Promise<number>} number of files unlinked
|
|
42
|
+
*/
|
|
43
|
+
export async function cleanupStaleTmpFiles(filePath, maxAgeMs = 3600000) {
|
|
44
|
+
if (!filePath || typeof filePath !== 'string') return 0;
|
|
45
|
+
try {
|
|
46
|
+
const dir = path.dirname(filePath);
|
|
47
|
+
const base = path.basename(filePath);
|
|
48
|
+
const prefix = `.${base}.`;
|
|
49
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
let cleaned = 0;
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isFile()) continue;
|
|
54
|
+
if (entry.name.startsWith(prefix) && entry.name.endsWith('.tmp')) {
|
|
55
|
+
const fullPath = path.join(dir, entry.name);
|
|
56
|
+
try {
|
|
57
|
+
const st = await fs.stat(fullPath);
|
|
58
|
+
if (now - st.mtimeMs > maxAgeMs) {
|
|
59
|
+
await fs.unlink(fullPath);
|
|
60
|
+
cleaned++;
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return cleaned;
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
24
68
|
}
|
|
25
|
-
await fs.rename(tmp, filePath);
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
/**
|
package/lib/client.js
CHANGED
|
@@ -61,6 +61,24 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
|
|
62
62
|
sectionFailover: 'Failover Triggers & Error Codes',
|
|
63
63
|
sectionFailoverDesc: 'Select error conditions that trigger automatic switching to the next available key.',
|
|
64
|
+
maxAttemptsLabel: 'Max Attempts (Budget)',
|
|
65
|
+
maxDurationLabel: 'Max Duration ms (Budget)',
|
|
66
|
+
traceTitle: 'Key Selection Traces',
|
|
67
|
+
traceDesc: 'Candidate evaluation history, skips and selection decisions for requests.',
|
|
68
|
+
traceRefresh: 'Refresh Traces',
|
|
69
|
+
traceClear: 'Clear History',
|
|
70
|
+
traceEmpty: 'No selection traces recorded yet.',
|
|
71
|
+
traceSelected: 'Selected Key',
|
|
72
|
+
traceOutcome: 'Outcome',
|
|
73
|
+
drainTitle: 'Managed Key Drain',
|
|
74
|
+
drainDesc: 'Safely transition a key out of rotation waiting for active requests to finish.',
|
|
75
|
+
drainKeyPlaceholder: 'Key ref (e.g. DEEPSEEK_API_KEY_2)',
|
|
76
|
+
drainStart: 'Start Drain',
|
|
77
|
+
drainCancel: 'Cancel Drain',
|
|
78
|
+
drainActiveStreams: 'Active requests',
|
|
79
|
+
drainCanComplete: 'Ready to remove',
|
|
80
|
+
drainDraining: 'Draining Keys',
|
|
81
|
+
drainEmpty: 'No keys currently draining.',
|
|
64
82
|
sectionTiming: 'Timing & Rotation Schedule',
|
|
65
83
|
sectionTimingDesc: 'Configure cooldown durations and scheduled periodic rotation windows.',
|
|
66
84
|
sectionBackup: 'Backup & Snapshots',
|
|
@@ -317,6 +335,24 @@ window.__ModuleLoader__.load({
|
|
|
317
335
|
sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
|
|
318
336
|
sectionFailover: '故障转移触发器与错误代码',
|
|
319
337
|
sectionFailoverDesc: '选择触发自动切换至下一个可用密钥的错误条件。',
|
|
338
|
+
maxAttemptsLabel: '最大重试次数 (预算)',
|
|
339
|
+
maxDurationLabel: '最大超时毫秒 (预算)',
|
|
340
|
+
traceTitle: '密钥选择追踪 (Trace)',
|
|
341
|
+
traceDesc: '查看请求的候选密钥评估、跳过原因和最终选择决策。',
|
|
342
|
+
traceRefresh: '刷新追踪',
|
|
343
|
+
traceClear: '清空历史',
|
|
344
|
+
traceEmpty: '暂无选择追踪记录。',
|
|
345
|
+
traceSelected: '所选密钥',
|
|
346
|
+
traceOutcome: '结果',
|
|
347
|
+
drainTitle: '安全下线密钥 (Drain)',
|
|
348
|
+
drainDesc: '平滑下线密钥并等待当前在途请求完成,避免中途断流。',
|
|
349
|
+
drainKeyPlaceholder: '密钥标识 (例如 DEEPSEEK_API_KEY_2)',
|
|
350
|
+
drainStart: '开始下线',
|
|
351
|
+
drainCancel: '取消下线',
|
|
352
|
+
drainActiveStreams: '在途请求数',
|
|
353
|
+
drainCanComplete: '可安全移除',
|
|
354
|
+
drainDraining: '下线中的密钥',
|
|
355
|
+
drainEmpty: '当前没有正在下线的密钥。',
|
|
320
356
|
sectionTiming: '时间与轮换计划',
|
|
321
357
|
sectionTimingDesc: '配置冷却持续时间和定期轮换计划窗口。',
|
|
322
358
|
sectionBackup: '备份与快照',
|
|
@@ -1257,6 +1293,16 @@ window.__ModuleLoader__.load({
|
|
|
1257
1293
|
// Auto-Discovery State (#491)
|
|
1258
1294
|
const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
|
|
1259
1295
|
const [autoImporting, setAutoImporting] = React.useState(false);
|
|
1296
|
+
|
|
1297
|
+
// Selection Trace State (#461)
|
|
1298
|
+
const [selectionTraces, setSelectionTraces] = React.useState([]);
|
|
1299
|
+
const [loadingTraces, setLoadingTraces] = React.useState(false);
|
|
1300
|
+
|
|
1301
|
+
// Managed Key Drain State (#463)
|
|
1302
|
+
const [drainingKeys, setDrainingKeys] = React.useState([]);
|
|
1303
|
+
const [drainRefInput, setDrainRefInput] = React.useState('');
|
|
1304
|
+
const [loadingDrain, setLoadingDrain] = React.useState(false);
|
|
1305
|
+
const [drainActionError, setDrainActionError] = React.useState('');
|
|
1260
1306
|
const [autoImportMsg, setAutoImportMsg] = React.useState('');
|
|
1261
1307
|
|
|
1262
1308
|
// Test Matrix State (#490)
|
|
@@ -1472,6 +1518,70 @@ window.__ModuleLoader__.load({
|
|
|
1472
1518
|
}
|
|
1473
1519
|
};
|
|
1474
1520
|
|
|
1521
|
+
const loadTraces = async () => {
|
|
1522
|
+
setLoadingTraces(true);
|
|
1523
|
+
try {
|
|
1524
|
+
const res = await fetch('/dsh-key-rotation/trace?limit=20');
|
|
1525
|
+
const data = await res.json();
|
|
1526
|
+
if (data && data.ok) {
|
|
1527
|
+
setSelectionTraces(data.traces || []);
|
|
1528
|
+
}
|
|
1529
|
+
} catch (_) {}
|
|
1530
|
+
finally {
|
|
1531
|
+
setLoadingTraces(false);
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
const clearTraces = async () => {
|
|
1536
|
+
try {
|
|
1537
|
+
await fetch('/dsh-key-rotation/trace', { method: 'DELETE' });
|
|
1538
|
+
setSelectionTraces([]);
|
|
1539
|
+
} catch (_) {}
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
const loadDrainStatus = async () => {
|
|
1543
|
+
setLoadingDrain(true);
|
|
1544
|
+
try {
|
|
1545
|
+
const res = await fetch('/dsh-key-rotation/drain');
|
|
1546
|
+
const data = await res.json();
|
|
1547
|
+
if (data && data.ok) {
|
|
1548
|
+
setDrainingKeys(data.draining || []);
|
|
1549
|
+
}
|
|
1550
|
+
} catch (_) {}
|
|
1551
|
+
finally {
|
|
1552
|
+
setLoadingDrain(false);
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
const startKeyDrain = async (ref) => {
|
|
1557
|
+
if (!ref || !ref.trim()) return;
|
|
1558
|
+
setDrainActionError('');
|
|
1559
|
+
try {
|
|
1560
|
+
const res = await fetch('/dsh-key-rotation/drain', {
|
|
1561
|
+
method: 'POST',
|
|
1562
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1563
|
+
body: JSON.stringify({ ref: ref.trim() }),
|
|
1564
|
+
});
|
|
1565
|
+
const data = await res.json();
|
|
1566
|
+
if (data && data.ok) {
|
|
1567
|
+
setDrainRefInput('');
|
|
1568
|
+
await loadDrainStatus();
|
|
1569
|
+
} else {
|
|
1570
|
+
setDrainActionError(data?.error?.message || 'Drain request failed');
|
|
1571
|
+
}
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
setDrainActionError(String(e?.message || e));
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
const cancelKeyDrain = async (ref) => {
|
|
1578
|
+
if (!ref) return;
|
|
1579
|
+
try {
|
|
1580
|
+
await fetch(`/dsh-key-rotation/drain?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' });
|
|
1581
|
+
await loadDrainStatus();
|
|
1582
|
+
} catch (_) {}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1475
1585
|
const [optimisticReset, setOptimisticReset] = React.useState({});
|
|
1476
1586
|
const [selected, setSelected] = React.useState(new Set());
|
|
1477
1587
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
@@ -2928,6 +3038,10 @@ window.__ModuleLoader__.load({
|
|
|
2928
3038
|
field(t('rpmLimitLabel'), textInput(String(val.rpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, rpmLimit: Number(v) || 0 })))),
|
|
2929
3039
|
field(t('tpmLimitLabel'), textInput(String(val.tpmLimit ?? 0), (v) => setField((cur) => ({ ...cur, tpmLimit: Number(v) || 0 }))))
|
|
2930
3040
|
),
|
|
3041
|
+
h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
|
|
3042
|
+
field(t('maxAttemptsLabel'), textInput(String(val.maxAttempts ?? 0), (v) => setField((cur) => ({ ...cur, maxAttempts: Number(v) || 0 })))),
|
|
3043
|
+
field(t('maxDurationLabel'), textInput(String(val.maxDurationMs ?? 0), (v) => setField((cur) => ({ ...cur, maxDurationMs: Number(v) || 0 }))))
|
|
3044
|
+
),
|
|
2931
3045
|
h('div', { className: 'krot-grid-3', style: { marginTop: '10px' } },
|
|
2932
3046
|
field(t('routingStrategyLabel'), h('select', {
|
|
2933
3047
|
className: 'krot-in',
|
|
@@ -3017,6 +3131,68 @@ window.__ModuleLoader__.load({
|
|
|
3017
3131
|
) : null
|
|
3018
3132
|
);
|
|
3019
3133
|
|
|
3134
|
+
const drainSection = h('div', { className: 'krot-section-card' },
|
|
3135
|
+
h('div', { className: 'krot-section-title' },
|
|
3136
|
+
h('span', null, '⏳ ' + t('drainTitle')),
|
|
3137
|
+
h('span', { className: 'krot-badge ' + (drainingKeys.length > 0 ? 'krot-badge-warn' : 'krot-badge-ok') },
|
|
3138
|
+
drainingKeys.length > 0 ? (drainingKeys.length + ' ' + t('drainDraining')) : t('drainEmpty')
|
|
3139
|
+
)
|
|
3140
|
+
),
|
|
3141
|
+
h('div', { className: 'krot-section-desc' }, t('drainDesc')),
|
|
3142
|
+
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '10px' } },
|
|
3143
|
+
h('input', {
|
|
3144
|
+
className: 'krot-in',
|
|
3145
|
+
placeholder: t('drainKeyPlaceholder'),
|
|
3146
|
+
value: drainRefInput,
|
|
3147
|
+
onChange: (e) => setDrainRefInput(e.target.value),
|
|
3148
|
+
style: { maxWidth: '240px' },
|
|
3149
|
+
}),
|
|
3150
|
+
btn(t('drainStart'), () => startKeyDrain(drainRefInput), { primary: true, disabled: !drainRefInput.trim() }),
|
|
3151
|
+
btn(t('traceRefresh'), loadDrainStatus, { disabled: loadingDrain })
|
|
3152
|
+
),
|
|
3153
|
+
drainActionError ? h('div', { className: 'krot-warn', style: { marginTop: '8px' } }, '⚠ ' + drainActionError) : null,
|
|
3154
|
+
drainingKeys.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
3155
|
+
drainingKeys.map((dk) => h('div', { key: dk.ref, className: 'krot-stat-box', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px' } },
|
|
3156
|
+
h('span', { style: { fontWeight: 600 } }, dk.ref),
|
|
3157
|
+
h('span', { className: 'krot-badge ' + (dk.canComplete ? 'krot-badge-ok' : 'krot-badge-warn') },
|
|
3158
|
+
dk.canComplete ? t('drainCanComplete') : (t('drainActiveStreams') + ': ' + dk.inFlight)
|
|
3159
|
+
),
|
|
3160
|
+
btn(t('drainCancel'), () => cancelKeyDrain(dk.ref), { danger: true })
|
|
3161
|
+
))
|
|
3162
|
+
) : null
|
|
3163
|
+
);
|
|
3164
|
+
|
|
3165
|
+
const traceSection = h('div', { className: 'krot-section-card' },
|
|
3166
|
+
h('div', { className: 'krot-section-title' },
|
|
3167
|
+
h('span', null, '🧭 ' + t('traceTitle')),
|
|
3168
|
+
h('span', { className: 'krot-badge krot-badge-ok' }, selectionTraces.length + ' traces')
|
|
3169
|
+
),
|
|
3170
|
+
h('div', { className: 'krot-section-desc' }, t('traceDesc')),
|
|
3171
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '10px' } },
|
|
3172
|
+
btn(loadingTraces ? '...' : t('traceRefresh'), loadTraces, { disabled: loadingTraces }),
|
|
3173
|
+
btn(t('traceClear'), clearTraces, { disabled: selectionTraces.length === 0 })
|
|
3174
|
+
),
|
|
3175
|
+
selectionTraces.length > 0 ? h('div', { style: { marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '280px', overflowY: 'auto' } },
|
|
3176
|
+
selectionTraces.slice().reverse().map((tr) => h('div', { key: tr.requestId, style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', padding: '8px', fontSize: '11px' } },
|
|
3177
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' } },
|
|
3178
|
+
h('span', { style: { fontWeight: 600 } }, (tr.provider || 'default') + (tr.model ? ' / ' + tr.model : '')),
|
|
3179
|
+
h('span', { className: 'krot-badge ' + (tr.outcome === 'SUCCESS' ? 'krot-badge-ok' : 'krot-badge-bad') }, tr.outcome)
|
|
3180
|
+
),
|
|
3181
|
+
h('div', { style: { color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } },
|
|
3182
|
+
tr.selectedRef ? (t('traceSelected') + ': ' + tr.selectedRef) : ''
|
|
3183
|
+
),
|
|
3184
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', paddingLeft: '8px', borderLeft: '2px solid var(--dsw-alias-border-l2)' } },
|
|
3185
|
+
(tr.steps || []).map((st, i) => h('div', { key: i, style: { display: 'flex', gap: '6px' } },
|
|
3186
|
+
h('span', { style: { color: st.decision === 'SELECTED' ? 'var(--dsw-alias-state-success-primary)' : st.decision === 'SKIPPED' ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-state-danger-primary)', fontWeight: 600 } }, st.decision),
|
|
3187
|
+
h('span', { style: { fontWeight: 600 } }, st.ref || '-'),
|
|
3188
|
+
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, st.reasonCode ? ('[' + st.reasonCode + ']') : ''),
|
|
3189
|
+
h('span', null, st.details || '')
|
|
3190
|
+
))
|
|
3191
|
+
)
|
|
3192
|
+
))
|
|
3193
|
+
) : h('div', { className: 'krot-hint', style: { marginTop: '8px' } }, t('traceEmpty'))
|
|
3194
|
+
);
|
|
3195
|
+
|
|
3020
3196
|
const backupSection = h('div', { className: 'krot-section-card' },
|
|
3021
3197
|
h('div', { className: 'krot-section-title' },
|
|
3022
3198
|
h('span', null, '💾 ' + t('sectionBackup')),
|
|
@@ -3305,6 +3481,8 @@ window.__ModuleLoader__.load({
|
|
|
3305
3481
|
poolsSection,
|
|
3306
3482
|
failoverSection,
|
|
3307
3483
|
timingSection,
|
|
3484
|
+
drainSection,
|
|
3485
|
+
traceSection,
|
|
3308
3486
|
backupSection,
|
|
3309
3487
|
simulatorSection,
|
|
3310
3488
|
),
|
package/lib/clone-lifecycle.js
CHANGED
|
@@ -148,7 +148,7 @@ export class CloneLifecycleManager {
|
|
|
148
148
|
startDrain(cloneId, provider, onDeregister) {
|
|
149
149
|
if (this.drainingClones.has(cloneId)) return;
|
|
150
150
|
|
|
151
|
-
markKeyDraining(cloneId, provider);
|
|
151
|
+
markKeyDraining(cloneId, provider, this.keyDrainManager);
|
|
152
152
|
const entry = {
|
|
153
153
|
cloneId,
|
|
154
154
|
provider,
|
|
@@ -176,7 +176,7 @@ export class CloneLifecycleManager {
|
|
|
176
176
|
|
|
177
177
|
if (inFlight === 0) {
|
|
178
178
|
this.drainingClones.delete(cloneId);
|
|
179
|
-
cancelKeyDrain(cloneId);
|
|
179
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
180
180
|
if (this.logger?.info) {
|
|
181
181
|
this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${entry.provider}) drained cleanly; safe to deregister`);
|
|
182
182
|
}
|
|
@@ -191,11 +191,11 @@ export class CloneLifecycleManager {
|
|
|
191
191
|
|
|
192
192
|
cancelDrain(cloneId) {
|
|
193
193
|
this.drainingClones.delete(cloneId);
|
|
194
|
-
cancelKeyDrain(cloneId);
|
|
194
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
isDraining(cloneId) {
|
|
198
|
-
return this.drainingClones.has(cloneId) || isKeyDraining(cloneId);
|
|
198
|
+
return this.drainingClones.has(cloneId) || isKeyDraining(cloneId, this.keyDrainManager);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
getDrainingList() {
|
|
@@ -209,7 +209,7 @@ export class CloneLifecycleManager {
|
|
|
209
209
|
|
|
210
210
|
destroy() {
|
|
211
211
|
for (const cloneId of this.drainingClones.keys()) {
|
|
212
|
-
cancelKeyDrain(cloneId);
|
|
212
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
213
213
|
}
|
|
214
214
|
this.drainingClones.clear();
|
|
215
215
|
}
|
package/lib/concurrency.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
|
|
1
|
+
// concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
|
|
2
2
|
|
|
3
3
|
const CONCURRENCY_DEFAULT_LIMIT = 0;
|
|
4
|
-
|
|
4
|
+
// Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
|
|
5
|
+
const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
5
6
|
|
|
6
7
|
export class ConcurrencyTracker {
|
|
7
8
|
constructor(opts) {
|
|
@@ -42,6 +43,14 @@ export class ConcurrencyTracker {
|
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
touch(ref, now) {
|
|
47
|
+
now = now || Date.now();
|
|
48
|
+
const e = this._inFlight.get(ref);
|
|
49
|
+
if (e && e.count > 0) {
|
|
50
|
+
e.lastAcquired = now;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
release(ref, now) {
|
|
46
55
|
now = now || Date.now();
|
|
47
56
|
const e = this._inFlight.get(ref);
|
|
@@ -55,13 +64,24 @@ export class ConcurrencyTracker {
|
|
|
55
64
|
if (!ref) return 0;
|
|
56
65
|
const e = this._inFlight.get(ref);
|
|
57
66
|
if (!e) return 0;
|
|
58
|
-
if (now - e.lastAcquired > this._staleMs)
|
|
67
|
+
if (now - e.lastAcquired > this._staleMs) {
|
|
68
|
+
e.count = 0;
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
59
71
|
return e.count;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
snapshot() {
|
|
74
|
+
snapshot(now) {
|
|
75
|
+
now = now || Date.now();
|
|
63
76
|
const out = {};
|
|
64
|
-
for (const [k, v] of this._inFlight)
|
|
77
|
+
for (const [k, v] of this._inFlight) {
|
|
78
|
+
let count = v.count;
|
|
79
|
+
if (now - v.lastAcquired > this._staleMs) {
|
|
80
|
+
count = 0;
|
|
81
|
+
v.count = 0;
|
|
82
|
+
}
|
|
83
|
+
out[k] = { count };
|
|
84
|
+
}
|
|
65
85
|
return out;
|
|
66
86
|
}
|
|
67
87
|
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { KeyDrainManager } from './key-drain.js';
|
|
2
|
+
import { SelectionTraceStore } from './selection-trace.js';
|
|
3
|
+
import { DiagBuffer } from './ops-telemetry.js';
|
|
1
4
|
import { createResolver } from './resolver.js';
|
|
2
5
|
import { decryptSecret } from './crypto-storage.js';
|
|
3
6
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
@@ -98,7 +101,7 @@ const SettingsSchema = Schema.object({
|
|
|
98
101
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
99
102
|
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
100
103
|
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
101
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']).default('round-robin'),
|
|
104
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']).default('round-robin'),
|
|
102
105
|
rpmLimit: Schema.number().default(0),
|
|
103
106
|
tpmLimit: Schema.number().default(0),
|
|
104
107
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
@@ -148,7 +151,7 @@ const SettingsSchema = Schema.object({
|
|
|
148
151
|
})).default({}),
|
|
149
152
|
cooldownMs: Schema.number(),
|
|
150
153
|
maxCooldownMs: Schema.number(),
|
|
151
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']),
|
|
154
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']),
|
|
152
155
|
proactiveRateLimitGuard: Schema.boolean(),
|
|
153
156
|
rpmLimit: Schema.number(),
|
|
154
157
|
tpmLimit: Schema.number(),
|
|
@@ -196,13 +199,16 @@ export function apply(ctx, config = {}) {
|
|
|
196
199
|
const latencyHistogram = new LatencyHistogram();
|
|
197
200
|
const quotaStore = new QuotaStore();
|
|
198
201
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
202
|
+
const keyDrainManager = new KeyDrainManager();
|
|
203
|
+
const selectionTraceStore = new SelectionTraceStore();
|
|
204
|
+
const diagBuffer = new DiagBuffer();
|
|
199
205
|
|
|
200
206
|
const moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
|
|
201
207
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
202
208
|
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
203
209
|
const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
204
210
|
const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
|
|
205
|
-
const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger });
|
|
211
|
+
const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger, keyDrainManager });
|
|
206
212
|
ctx.effect(() => () => { cloneLifecycle.destroy(); }, 'dsh-key-rotation: clone lifecycle cleanup');
|
|
207
213
|
|
|
208
214
|
const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
|
|
@@ -214,7 +220,11 @@ export function apply(ctx, config = {}) {
|
|
|
214
220
|
|
|
215
221
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
216
222
|
setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
217
|
-
setupKeyDrainEffect(ctx);
|
|
223
|
+
setupKeyDrainEffect(ctx, keyDrainManager);
|
|
224
|
+
ctx.effect(() => () => {
|
|
225
|
+
selectionTraceStore.clear();
|
|
226
|
+
diagBuffer.clear();
|
|
227
|
+
}, 'dsh-key-rotation: telemetry cleanup');
|
|
218
228
|
|
|
219
229
|
const poolState = new Map();
|
|
220
230
|
const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
|
|
@@ -363,6 +373,8 @@ export function apply(ctx, config = {}) {
|
|
|
363
373
|
showChatHeaderChip: cfg.showChatHeaderChip !== false,
|
|
364
374
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
365
375
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
376
|
+
maxAttempts: typeof cfg.maxAttempts === 'number' && cfg.maxAttempts > 0 ? Math.floor(cfg.maxAttempts) : 0,
|
|
377
|
+
maxDurationMs: typeof cfg.maxDurationMs === 'number' && cfg.maxDurationMs > 0 ? Math.floor(cfg.maxDurationMs) : 0,
|
|
366
378
|
index, pools,
|
|
367
379
|
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
|
|
368
380
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
|
@@ -461,6 +473,9 @@ export function apply(ctx, config = {}) {
|
|
|
461
473
|
circuitBreaker: moduleBreaker,
|
|
462
474
|
notifyQueue: moduleNotifyQueue,
|
|
463
475
|
quotaStore,
|
|
476
|
+
keyDrainManager,
|
|
477
|
+
selectionTraceStore,
|
|
478
|
+
diagBuffer,
|
|
464
479
|
});
|
|
465
480
|
|
|
466
481
|
ctx.effect(() => {
|
package/lib/key-drain.js
CHANGED
|
@@ -1,56 +1,91 @@
|
|
|
1
|
-
// lib/key-drain.js — Managed key draining and graceful replacement (#463, #502).
|
|
1
|
+
// lib/key-drain.js — Managed key draining and graceful replacement (#463, #502, #544).
|
|
2
2
|
// Allows draining an old key ref (no new dispatches assigned) while letting
|
|
3
3
|
// in-flight active streams complete cleanly before permanent removal.
|
|
4
|
+
// KeyDrainManager provides instance-level isolation (#544).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
export class KeyDrainManager {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
markKeyDraining(ref, provider = '') {
|
|
12
|
+
if (!ref) return false;
|
|
13
|
+
this.drainingKeys.set(ref, {
|
|
14
|
+
ref,
|
|
15
|
+
provider,
|
|
16
|
+
startedAt: Date.now(),
|
|
17
|
+
});
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isKeyDraining(ref) {
|
|
22
|
+
if (!ref) return false;
|
|
23
|
+
return this.drainingKeys.has(ref);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
cancelKeyDrain(ref) {
|
|
27
|
+
if (!ref) return false;
|
|
28
|
+
return this.drainingKeys.delete(ref);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getDrainingStatus(ref, concurrencyTracker) {
|
|
32
|
+
if (!ref || !this.drainingKeys.has(ref)) return null;
|
|
33
|
+
const entry = this.drainingKeys.get(ref);
|
|
34
|
+
const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
|
|
35
|
+
? concurrencyTracker.getActive(ref)
|
|
36
|
+
: (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
|
|
37
|
+
? concurrencyTracker.getInFlight(ref)
|
|
38
|
+
: 0);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ref,
|
|
42
|
+
provider: entry.provider,
|
|
43
|
+
startedAt: entry.startedAt,
|
|
44
|
+
elapsedMs: Date.now() - entry.startedAt,
|
|
45
|
+
inFlight,
|
|
46
|
+
canComplete: inFlight === 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAllDrainingKeys(concurrencyTracker) {
|
|
51
|
+
const result = [];
|
|
52
|
+
for (const ref of this.drainingKeys.keys()) {
|
|
53
|
+
result.push(this.getDrainingStatus(ref, concurrencyTracker));
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
6
57
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
ref,
|
|
11
|
-
provider,
|
|
12
|
-
startedAt: Date.now(),
|
|
13
|
-
});
|
|
14
|
-
return true;
|
|
58
|
+
clearAllDraining() {
|
|
59
|
+
this.drainingKeys.clear();
|
|
60
|
+
}
|
|
15
61
|
}
|
|
16
62
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
63
|
+
const defaultDrainManager = new KeyDrainManager();
|
|
64
|
+
|
|
65
|
+
export function markKeyDraining(ref, provider = '', manager = null) {
|
|
66
|
+
return (manager || defaultDrainManager).markKeyDraining(ref, provider);
|
|
20
67
|
}
|
|
21
68
|
|
|
22
|
-
export function
|
|
23
|
-
|
|
24
|
-
return drainingKeys.delete(ref);
|
|
69
|
+
export function isKeyDraining(ref, manager = null) {
|
|
70
|
+
return (manager || defaultDrainManager).isKeyDraining(ref);
|
|
25
71
|
}
|
|
26
72
|
|
|
27
|
-
export function
|
|
28
|
-
|
|
29
|
-
const entry = drainingKeys.get(ref);
|
|
30
|
-
const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
|
|
31
|
-
? concurrencyTracker.getActive(ref)
|
|
32
|
-
: (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
|
|
33
|
-
? concurrencyTracker.getInFlight(ref)
|
|
34
|
-
: 0);
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
ref,
|
|
38
|
-
provider: entry.provider,
|
|
39
|
-
startedAt: entry.startedAt,
|
|
40
|
-
elapsedMs: Date.now() - entry.startedAt,
|
|
41
|
-
inFlight,
|
|
42
|
-
canComplete: inFlight === 0,
|
|
43
|
-
};
|
|
73
|
+
export function cancelKeyDrain(ref, manager = null) {
|
|
74
|
+
return (manager || defaultDrainManager).cancelKeyDrain(ref);
|
|
44
75
|
}
|
|
45
76
|
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return
|
|
77
|
+
export function getDrainingStatus(ref, concurrencyTracker, manager = null) {
|
|
78
|
+
return (manager || defaultDrainManager).getDrainingStatus(ref, concurrencyTracker);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getAllDrainingKeys(concurrencyTracker, manager = null) {
|
|
82
|
+
return (manager || defaultDrainManager).getAllDrainingKeys(concurrencyTracker);
|
|
52
83
|
}
|
|
53
84
|
|
|
54
|
-
export function clearAllDraining() {
|
|
55
|
-
|
|
85
|
+
export function clearAllDraining(manager = null) {
|
|
86
|
+
if (manager) {
|
|
87
|
+
manager.clearAllDraining();
|
|
88
|
+
} else {
|
|
89
|
+
defaultDrainManager.clearAllDraining();
|
|
90
|
+
}
|
|
56
91
|
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
3
|
import { decryptSecret } from './crypto-storage.js';
|
|
4
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
5
|
+
import { cleanupStaleTmpFiles } from './atomic-io.js';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { envValue } from './pool.js';
|
|
7
8
|
import { clearAllDraining } from './key-drain.js';
|
|
@@ -97,6 +98,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
97
98
|
dataDir: hostDirs[0],
|
|
98
99
|
});
|
|
99
100
|
if (cfg0.persistenceEnabled !== false && resolvedPath) {
|
|
101
|
+
cleanupStaleTmpFiles(resolvedPath).catch(() => {});
|
|
100
102
|
statePersistence = new StatePersistence({ filePath: resolvedPath });
|
|
101
103
|
statePersistence.load().then((snap) => {
|
|
102
104
|
if (!snap) return;
|
|
@@ -251,10 +253,10 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
251
253
|
}, 'dsh-key-rotation: proactive health probing');
|
|
252
254
|
}
|
|
253
255
|
|
|
254
|
-
export function setupKeyDrainEffect(ctx) {
|
|
256
|
+
export function setupKeyDrainEffect(ctx, keyDrainManager = null) {
|
|
255
257
|
return ctx.effect(() => {
|
|
256
258
|
return () => {
|
|
257
|
-
clearAllDraining();
|
|
259
|
+
clearAllDraining(keyDrainManager);
|
|
258
260
|
};
|
|
259
261
|
}, 'dsh-key-rotation: clear draining keys on teardown');
|
|
260
262
|
}
|