@goodandready/dsh-key-rotation 0.8.74 → 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 +10 -0
- package/lib/atomic-io.js +47 -4
- package/lib/client.js +178 -0
- package/lib/concurrency.js +25 -5
- package/lib/index.js +2 -0
- package/lib/lifecycle.js +2 -0
- package/lib/ops-telemetry.js +3 -2
- package/lib/pool-builder.js +14 -0
- package/lib/request-budget.js +8 -1
- package/lib/rotate.js +628 -597
- package/lib/safe-fetch.js +30 -28
- package/package.json +9 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.8.74 (2026-10-30)
|
|
4
14
|
|
|
5
15
|
### Rotation & Routing Reliability (Block 2 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/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
|
@@ -373,6 +373,8 @@ export function apply(ctx, config = {}) {
|
|
|
373
373
|
showChatHeaderChip: cfg.showChatHeaderChip !== false,
|
|
374
374
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
375
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,
|
|
376
378
|
index, pools,
|
|
377
379
|
breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram, trafficSmoother,
|
|
378
380
|
providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
|
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;
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -174,14 +174,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
174
174
|
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
|
+
const traceStore = deps.selectionTraceStore || null;
|
|
177
178
|
if (req.method === 'DELETE') {
|
|
178
|
-
clearSelectionTraces();
|
|
179
|
+
clearSelectionTraces(traceStore);
|
|
179
180
|
json(res, 200, { ok: true, message: 'trace history cleared' });
|
|
180
181
|
return;
|
|
181
182
|
}
|
|
182
183
|
const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
|
|
183
184
|
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
184
|
-
json(res, 200, { ok: true, traces: getSelectionTraces(limit) });
|
|
185
|
+
json(res, 200, { ok: true, traces: getSelectionTraces(limit, traceStore) });
|
|
185
186
|
},
|
|
186
187
|
}), 'dsh-key-rotation: trace route');
|
|
187
188
|
|
package/lib/pool-builder.js
CHANGED
|
@@ -39,6 +39,8 @@ export function buildPoolItem({
|
|
|
39
39
|
useKeyRef = null,
|
|
40
40
|
trafficSmoothingBurst = null,
|
|
41
41
|
maxQueueWaitMs = null,
|
|
42
|
+
maxAttempts = null,
|
|
43
|
+
maxDurationMs = null,
|
|
42
44
|
}) {
|
|
43
45
|
const seen = new Set();
|
|
44
46
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -143,6 +145,12 @@ const weightsMap = {};
|
|
|
143
145
|
maxQueueWaitMs: typeof maxQueueWaitMs === 'number' && Number.isFinite(maxQueueWaitMs) && maxQueueWaitMs >= 0
|
|
144
146
|
? Math.floor(maxQueueWaitMs)
|
|
145
147
|
: 800,
|
|
148
|
+
maxAttempts: typeof maxAttempts === 'number' && Number.isFinite(maxAttempts) && maxAttempts > 0
|
|
149
|
+
? Math.floor(maxAttempts)
|
|
150
|
+
: 0,
|
|
151
|
+
maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
|
|
152
|
+
? Math.floor(maxDurationMs)
|
|
153
|
+
: 0,
|
|
146
154
|
};
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -178,6 +186,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
178
186
|
const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
|
|
179
187
|
const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
|
|
180
188
|
const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
|
|
189
|
+
const poolAttempts = typeof p.maxAttempts === 'number' ? p.maxAttempts : (cfg.maxAttempts ?? 0);
|
|
190
|
+
const poolDuration = typeof p.maxDurationMs === 'number' ? p.maxDurationMs : (cfg.maxDurationMs ?? 0);
|
|
181
191
|
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
182
192
|
provider: p.provider,
|
|
183
193
|
model: null,
|
|
@@ -186,6 +196,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
186
196
|
pinnedRef: p.pinnedRef ?? p.useKeyRef,
|
|
187
197
|
trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
188
198
|
maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
199
|
+
maxAttempts: poolAttempts,
|
|
200
|
+
maxDurationMs: poolDuration,
|
|
189
201
|
}, provRpm, provTpm, provConcurrency);
|
|
190
202
|
if (pool) {
|
|
191
203
|
for (const ref of pool.refs) {
|
|
@@ -216,6 +228,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
216
228
|
pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
|
|
217
229
|
trafficSmoothingBurst: mp.trafficSmoothingBurst ?? p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
218
230
|
maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
|
231
|
+
maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
|
|
232
|
+
maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
|
|
219
233
|
}, modelRpm, modelTpm, modelConcurrency);
|
|
220
234
|
if (!mpool) continue;
|
|
221
235
|
mpool.basePool = pool;
|
package/lib/request-budget.js
CHANGED
|
@@ -22,13 +22,14 @@ export function createRequestBudget(options = {}) {
|
|
|
22
22
|
|
|
23
23
|
const controller = new AbortController();
|
|
24
24
|
let timerId = null;
|
|
25
|
+
let onCallerAbort = null;
|
|
25
26
|
|
|
26
27
|
// Chain caller signal if provided
|
|
27
28
|
if (options.signal) {
|
|
28
29
|
if (options.signal.aborted) {
|
|
29
30
|
controller.abort(options.signal.reason);
|
|
30
31
|
} else {
|
|
31
|
-
|
|
32
|
+
onCallerAbort = () => {
|
|
32
33
|
controller.abort(options.signal.reason);
|
|
33
34
|
cleanup();
|
|
34
35
|
};
|
|
@@ -52,6 +53,12 @@ export function createRequestBudget(options = {}) {
|
|
|
52
53
|
clearTimeout(timerId);
|
|
53
54
|
timerId = null;
|
|
54
55
|
}
|
|
56
|
+
if (options.signal && onCallerAbort) {
|
|
57
|
+
try {
|
|
58
|
+
options.signal.removeEventListener('abort', onCallerAbort);
|
|
59
|
+
} catch {}
|
|
60
|
+
onCallerAbort = null;
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
return {
|