@goodandready/dsh-key-rotation 0.8.61 → 0.8.62
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 +15 -0
- package/lib/client.js +189 -0
- package/lib/index.js +2 -1
- package/lib/ops-keys.js +239 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-test.js +111 -0
- package/lib/pool-state.js +1 -1
- package/lib/pool.js +30 -1
- package/lib/resolver.js +26 -7
- package/lib/rotate.js +16 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.62 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Session-Aware Sticky Routing Affinity for Prompt Caching (#489)**:
|
|
7
|
+
- Enhanced `sticky` routing strategy with LRU session affinity (`pool.state.sessionSticky`, capped at 500 active sessions).
|
|
8
|
+
- Parallel DSH chat sessions or multi-agent subtasks retain dedicated sticky keys based on `sessionId` (`options.sessionId` / `options.session` / `options.metadata.sessionId`), preventing cross-session cache evictions and maximizing prompt prefix cache hit rates for Anthropic, DeepSeek, and OpenAI.
|
|
9
|
+
- Graceful failover to the next healthy candidate if a session's pinned key is in cooldown or quota-exhausted; automatic eviction from session map on error.
|
|
10
|
+
- **1-Click Test Matrix Dashboard (#490)**:
|
|
11
|
+
- Added `/dsh-key-rotation/test-matrix` endpoint in `lib/ops-test.js` supporting batch validation of all configured keys across all pools with bounded concurrency (5 workers) and latency tracking.
|
|
12
|
+
- Added "⚡ Test All Keys" action button in the WebUI Settings Card with an interactive results modal displaying operational key status, latency in milliseconds, and error details.
|
|
13
|
+
- **Zero-Config Pool Auto-Discovery & 1-Click Import (#491)**:
|
|
14
|
+
- Added `/dsh-key-rotation/discover` and `/dsh-key-rotation/auto-import` endpoints in `lib/ops-keys.js`.
|
|
15
|
+
- Automatically scans `~/.dsh/.credentials.yaml` and environment variables for unconfigured provider keys matching standard patterns (`DEEPSEEK_API_KEY`, `OPENCODE_GO_API_KEY_*`, `OPENAI_API_KEY`, `GROQ_API_KEY`, etc.), excluding keys already assigned to rotation pools.
|
|
16
|
+
- Added smart auto-discovery banner to the Settings Card with 1-click import into provider pools.
|
|
17
|
+
|
|
3
18
|
## 0.8.61 - 2026-10-15
|
|
4
19
|
|
|
5
20
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -235,6 +235,19 @@ window.__ModuleLoader__.load({
|
|
|
235
235
|
updateFailed: 'Update failed',
|
|
236
236
|
currentVersion: 'Current version',
|
|
237
237
|
latestVersion: 'Latest version',
|
|
238
|
+
testAllKeys: 'Test All Keys',
|
|
239
|
+
testingMatrix: 'Testing Keys…',
|
|
240
|
+
matrixModalTitle: '⚡ Key Test Matrix',
|
|
241
|
+
matrixModalDesc: 'Parallel probe of all configured credentials across all provider pools.',
|
|
242
|
+
matrixSummary: '{healthy}/{total} keys operational',
|
|
243
|
+
matrixLatency: 'Latency',
|
|
244
|
+
matrixStatus: 'Status',
|
|
245
|
+
matrixClose: 'Close',
|
|
246
|
+
discoverBannerTitle: '⚡ Unassigned API Keys Discovered',
|
|
247
|
+
discoverBannerDesc: 'Found {n} credentials in your environment (~/.dsh/.credentials.yaml / env) not yet assigned to any rotation pool.',
|
|
248
|
+
autoImportBtn: '1-Click Auto-Import',
|
|
249
|
+
autoImportSuccess: 'Imported {n} keys into rotation pools',
|
|
250
|
+
autoImporting: 'Importing…',
|
|
238
251
|
};
|
|
239
252
|
|
|
240
253
|
const zh = {
|
|
@@ -420,6 +433,19 @@ window.__ModuleLoader__.load({
|
|
|
420
433
|
updateFailed: '更新失败',
|
|
421
434
|
currentVersion: '当前版本',
|
|
422
435
|
latestVersion: '最新版本',
|
|
436
|
+
testAllKeys: '批量测试所有密钥',
|
|
437
|
+
testingMatrix: '正在测试密钥…',
|
|
438
|
+
matrixModalTitle: '⚡ 密钥测试矩阵',
|
|
439
|
+
matrixModalDesc: '并行探测所有提供商密钥池中配置的凭据。',
|
|
440
|
+
matrixSummary: '{healthy}/{total} 个密钥正常可用',
|
|
441
|
+
matrixLatency: '延迟',
|
|
442
|
+
matrixStatus: '状态',
|
|
443
|
+
matrixClose: '关闭',
|
|
444
|
+
discoverBannerTitle: '⚡ 发现未分配的 API 密钥',
|
|
445
|
+
discoverBannerDesc: '在当前环境 (~/.dsh/.credentials.yaml / env) 中发现 {n} 个未分配给轮换池的密钥。',
|
|
446
|
+
autoImportBtn: '一键自动导入',
|
|
447
|
+
autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
|
|
448
|
+
autoImporting: '正在导入…',
|
|
423
449
|
};
|
|
424
450
|
|
|
425
451
|
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
@@ -1127,6 +1153,73 @@ window.__ModuleLoader__.load({
|
|
|
1127
1153
|
const [simResult, setSimResult] = React.useState(null);
|
|
1128
1154
|
const [simError, setSimError] = React.useState('');
|
|
1129
1155
|
|
|
1156
|
+
// Auto-Discovery State (#491)
|
|
1157
|
+
const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
|
|
1158
|
+
const [autoImporting, setAutoImporting] = React.useState(false);
|
|
1159
|
+
const [autoImportMsg, setAutoImportMsg] = React.useState('');
|
|
1160
|
+
|
|
1161
|
+
// Test Matrix State (#490)
|
|
1162
|
+
const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
|
|
1163
|
+
const [testMatrixResults, setTestMatrixResults] = React.useState(null);
|
|
1164
|
+
const [testMatrixModal, setTestMatrixModal] = React.useState(false);
|
|
1165
|
+
|
|
1166
|
+
React.useEffect(() => {
|
|
1167
|
+
let mounted = true;
|
|
1168
|
+
fetch('/dsh-key-rotation/discover', {
|
|
1169
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
1170
|
+
})
|
|
1171
|
+
.then((r) => r.json())
|
|
1172
|
+
.then((data) => {
|
|
1173
|
+
if (mounted && data?.ok && Array.isArray(data.discovered)) {
|
|
1174
|
+
setDiscoveredKeys(data.discovered);
|
|
1175
|
+
}
|
|
1176
|
+
})
|
|
1177
|
+
.catch((e) => { void e; });
|
|
1178
|
+
return () => { mounted = false; };
|
|
1179
|
+
}, []);
|
|
1180
|
+
|
|
1181
|
+
const runTestMatrix = async () => {
|
|
1182
|
+
setTestMatrixRunning(true);
|
|
1183
|
+
setTestMatrixModal(true);
|
|
1184
|
+
try {
|
|
1185
|
+
const res = await fetch('/dsh-key-rotation/test-matrix', {
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
|
|
1188
|
+
});
|
|
1189
|
+
const data = await res.json();
|
|
1190
|
+
if (data?.ok) {
|
|
1191
|
+
setTestMatrixResults(data);
|
|
1192
|
+
}
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
setTestMatrixResults({ ok: false, failed: 1, total: 1, results: [{ provider: 'all', ref: 'matrix', ok: false, code: 'error', error: String(err?.message ?? err) }] });
|
|
1195
|
+
} finally {
|
|
1196
|
+
setTestMatrixRunning(false);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const runAutoImport = async () => {
|
|
1201
|
+
setAutoImporting(true);
|
|
1202
|
+
setAutoImportMsg('');
|
|
1203
|
+
try {
|
|
1204
|
+
const res = await fetch('/dsh-key-rotation/auto-import', {
|
|
1205
|
+
method: 'POST',
|
|
1206
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1207
|
+
body: JSON.stringify({}),
|
|
1208
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined,
|
|
1209
|
+
});
|
|
1210
|
+
const data = await res.json();
|
|
1211
|
+
if (data?.ok) {
|
|
1212
|
+
setAutoImportMsg(t('autoImportSuccess').replace('{n}', String(data.importedCount || 0)));
|
|
1213
|
+
setDiscoveredKeys([]);
|
|
1214
|
+
void load();
|
|
1215
|
+
}
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
setAutoImportMsg(String(err?.message ?? err));
|
|
1218
|
+
} finally {
|
|
1219
|
+
setAutoImporting(false);
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1130
1223
|
const runSimulator = async () => {
|
|
1131
1224
|
const activeProv = simProvider || (providersList[0]?.provider || '');
|
|
1132
1225
|
if (!activeProv) return;
|
|
@@ -2448,6 +2541,7 @@ window.__ModuleLoader__.load({
|
|
|
2448
2541
|
h('div', { className: 'krot-keys' }, providerRows),
|
|
2449
2542
|
h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
|
|
2450
2543
|
btn(t('addProvider'), addProvider, { primary: false }),
|
|
2544
|
+
btn('⚡ ' + (testMatrixRunning ? t('testingMatrix') : t('testAllKeys')), runTestMatrix, { disabled: testMatrixRunning || totalKeysCount === 0 }),
|
|
2451
2545
|
noProviders
|
|
2452
2546
|
),
|
|
2453
2547
|
h(UpdaterSection, { t })
|
|
@@ -2661,10 +2755,105 @@ window.__ModuleLoader__.load({
|
|
|
2661
2755
|
h('div', { className: 'krot-page-sub' }, t('header.sub'))
|
|
2662
2756
|
);
|
|
2663
2757
|
|
|
2758
|
+
const discoveryBanner = (discoveredKeys.length > 0 || autoImportMsg) ? h('div', {
|
|
2759
|
+
className: 'krot-section-card',
|
|
2760
|
+
style: {
|
|
2761
|
+
background: 'var(--dsw-alias-bg-hover)',
|
|
2762
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2763
|
+
marginBottom: '12px',
|
|
2764
|
+
padding: '12px 14px',
|
|
2765
|
+
display: 'flex',
|
|
2766
|
+
flexDirection: 'column',
|
|
2767
|
+
gap: '8px',
|
|
2768
|
+
}
|
|
2769
|
+
},
|
|
2770
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
2771
|
+
h('div', null,
|
|
2772
|
+
h('span', { style: { fontWeight: 600, fontSize: '13px' } }, t('discoverBannerTitle')),
|
|
2773
|
+
h('div', { className: 'krot-hint', style: { fontSize: '11px', marginTop: '2px' } },
|
|
2774
|
+
autoImportMsg || t('discoverBannerDesc').replace('{n}', String(discoveredKeys.length))
|
|
2775
|
+
),
|
|
2776
|
+
),
|
|
2777
|
+
discoveredKeys.length > 0 ? btn(autoImporting ? t('autoImporting') : t('autoImportBtn'), runAutoImport, {
|
|
2778
|
+
primary: true,
|
|
2779
|
+
disabled: autoImporting,
|
|
2780
|
+
}) : null
|
|
2781
|
+
),
|
|
2782
|
+
discoveredKeys.length > 0 ? h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '4px' } },
|
|
2783
|
+
discoveredKeys.slice(0, 10).map((d) => h('span', {
|
|
2784
|
+
key: d.ref,
|
|
2785
|
+
className: 'krot-badge',
|
|
2786
|
+
style: { fontSize: '10px', background: 'var(--dsw-alias-bg-layer-3)' }
|
|
2787
|
+
}, `${d.provider}: ${d.ref} ${d.keyTail ? '(' + d.keyTail + ')' : ''}`))
|
|
2788
|
+
) : null
|
|
2789
|
+
) : null;
|
|
2790
|
+
|
|
2791
|
+
const testMatrixDialog = testMatrixModal ? h('div', {
|
|
2792
|
+
className: 'krot-modal-backdrop',
|
|
2793
|
+
onClick: () => setTestMatrixModal(false),
|
|
2794
|
+
},
|
|
2795
|
+
h('div', {
|
|
2796
|
+
className: 'krot-modal-card',
|
|
2797
|
+
role: 'dialog',
|
|
2798
|
+
'aria-modal': 'true',
|
|
2799
|
+
onClick: (e) => e.stopPropagation(),
|
|
2800
|
+
style: {
|
|
2801
|
+
maxWidth: '560px',
|
|
2802
|
+
width: '90vw',
|
|
2803
|
+
maxHeight: '80vh',
|
|
2804
|
+
display: 'flex',
|
|
2805
|
+
flexDirection: 'column',
|
|
2806
|
+
gap: '12px',
|
|
2807
|
+
}
|
|
2808
|
+
},
|
|
2809
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
2810
|
+
h('span', { style: { fontWeight: 600, fontSize: '15px' } }, t('matrixModalTitle')),
|
|
2811
|
+
btn('✕', () => setTestMatrixModal(false), { className: 'krot-btn-sub' })
|
|
2812
|
+
),
|
|
2813
|
+
h('div', { className: 'krot-hint', style: { fontSize: '12px' } }, t('matrixModalDesc')),
|
|
2814
|
+
testMatrixRunning ? h('div', { style: { textAlign: 'center', padding: '20px' } }, t('testingMatrix')) : null,
|
|
2815
|
+
testMatrixResults ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', maxHeight: '50vh' } },
|
|
2816
|
+
h('div', { style: { fontWeight: 600, fontSize: '13px' } },
|
|
2817
|
+
t('matrixSummary').replace('{healthy}', String(testMatrixResults.healthy)).replace('{total}', String(testMatrixResults.total))
|
|
2818
|
+
),
|
|
2819
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
2820
|
+
(testMatrixResults.results || []).map((r, i) => h('div', {
|
|
2821
|
+
key: i,
|
|
2822
|
+
style: {
|
|
2823
|
+
display: 'flex',
|
|
2824
|
+
justifyContent: 'space-between',
|
|
2825
|
+
alignItems: 'center',
|
|
2826
|
+
padding: '6px 10px',
|
|
2827
|
+
borderRadius: '6px',
|
|
2828
|
+
background: 'var(--dsw-alias-bg-hover)',
|
|
2829
|
+
fontSize: '12px',
|
|
2830
|
+
}
|
|
2831
|
+
},
|
|
2832
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
2833
|
+
h('span', { style: { fontWeight: 500 } }, r.provider),
|
|
2834
|
+
h('span', { style: { fontFamily: 'monospace' } }, r.ref),
|
|
2835
|
+
r.keyTail ? h('span', { style: { color: 'var(--dsw-alias-text-sub)', fontSize: '11px' } }, `(${r.keyTail})`) : null
|
|
2836
|
+
),
|
|
2837
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
2838
|
+
h('span', { style: { color: 'var(--dsw-alias-text-sub)' } }, `${r.latencyMs || 0}ms`),
|
|
2839
|
+
h('span', {
|
|
2840
|
+
className: 'krot-badge ' + (r.ok ? 'krot-badge-ok' : 'krot-badge-bad')
|
|
2841
|
+
}, r.ok ? 'OK' : (r.code || 'FAIL'))
|
|
2842
|
+
)
|
|
2843
|
+
))
|
|
2844
|
+
)
|
|
2845
|
+
) : null,
|
|
2846
|
+
h('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '8px' } },
|
|
2847
|
+
btn(t('matrixClose'), () => setTestMatrixModal(false), { primary: true })
|
|
2848
|
+
)
|
|
2849
|
+
)
|
|
2850
|
+
) : null;
|
|
2664
2851
|
return h('div', { className: 'krot', 'aria-busy': saving || importing },
|
|
2665
2852
|
confirmModalEl,
|
|
2853
|
+
testMatrixDialog,
|
|
2666
2854
|
headerSection,
|
|
2667
2855
|
statsSection,
|
|
2856
|
+
discoveryBanner,
|
|
2668
2857
|
h('fieldset', { disabled: saving || importing || !state.writable,
|
|
2669
2858
|
style: { border: 0, padding: 0, margin: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '14px' } },
|
|
2670
2859
|
poolsSection,
|
package/lib/index.js
CHANGED
|
@@ -374,6 +374,7 @@ export function apply(ctx, config = {}) {
|
|
|
374
374
|
const resolve = createResolver({
|
|
375
375
|
buildRuntime,
|
|
376
376
|
currentPool: () => dispatchStorage.getStore()?.pool,
|
|
377
|
+
currentSession: () => dispatchStorage.getStore()?.sessionId,
|
|
377
378
|
onPicked: (pool, candidate, meta) => {
|
|
378
379
|
const store = dispatchStorage.getStore();
|
|
379
380
|
if (store && store.pool === pool) {
|
|
@@ -384,7 +385,7 @@ export function apply(ctx, config = {}) {
|
|
|
384
385
|
latencyHistogram,
|
|
385
386
|
concurrencyTracker,
|
|
386
387
|
});
|
|
387
|
-
credentials.resolve = async (ref) => resolve(ref, original);
|
|
388
|
+
credentials.resolve = async (ref, opts) => resolve(ref, original, opts);
|
|
388
389
|
credentials.__dshKeyRotationPatched = true;
|
|
389
390
|
return () => {
|
|
390
391
|
if (credentials.__dshKeyRotationPatched) {
|
package/lib/ops-keys.js
CHANGED
|
@@ -20,13 +20,138 @@ import {
|
|
|
20
20
|
RESET_PATH,
|
|
21
21
|
IMPORT_PATH,
|
|
22
22
|
DRAIN_PATH,
|
|
23
|
+
DISCOVER_PATH,
|
|
24
|
+
AUTO_IMPORT_PATH,
|
|
23
25
|
} from './ops-paths.js';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
24
29
|
import { markKeyDraining, isKeyDraining, cancelKeyDrain, getDrainingStatus, getAllDrainingKeys } from './key-drain.js';
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* @param {object} ctx cordis context
|
|
28
33
|
* @param {object} deps live dependencies from apply()
|
|
29
34
|
*/
|
|
35
|
+
const KNOWN_PROVIDER_PATTERNS = [
|
|
36
|
+
{ provider: 'deepseek', pattern: /^DEEPSEEK_API_KEY(?:_\w+)?$/i },
|
|
37
|
+
{ provider: 'opencode', pattern: /^OPENCODE(?:_GO)?_API_KEY(?:_\w+)?$/i },
|
|
38
|
+
{ provider: 'openai', pattern: /^OPENAI_API_KEY(?:_\w+)?$/i },
|
|
39
|
+
{ provider: 'anthropic', pattern: /^ANTHROPIC_API_KEY(?:_\w+)?$/i },
|
|
40
|
+
{ provider: 'openrouter', pattern: /^OPENROUTER_API_KEY(?:_\w+)?$/i },
|
|
41
|
+
{ provider: 'siliconflow', pattern: /^SILICONFLOW_API_KEY(?:_\w+)?$/i },
|
|
42
|
+
{ provider: 'groq', pattern: /^GROQ_API_KEY(?:_\w+)?$/i },
|
|
43
|
+
{ provider: 'mistral', pattern: /^MISTRAL_API_KEY(?:_\w+)?$/i },
|
|
44
|
+
{ provider: 'gemini', pattern: /^(?:GEMINI|GOOGLE)_API_KEY(?:_\w+)?$/i },
|
|
45
|
+
{ provider: 'ollama', pattern: /^OLLAMA_API_KEY(?:_\w+)?$/i },
|
|
46
|
+
{ provider: 'sensenova', pattern: /^SENSENOVA_API_KEY(?:_\w+)?$/i },
|
|
47
|
+
{ provider: 'cohere', pattern: /^COHERE_API_KEY(?:_\w+)?$/i },
|
|
48
|
+
{ provider: 'fal', pattern: /^FAL_API_KEY(?:_\w+)?$/i },
|
|
49
|
+
{ provider: 'together', pattern: /^TOGETHER_API_KEY(?:_\w+)?$/i },
|
|
50
|
+
{ provider: 'xai', pattern: /^XAI_API_KEY(?:_\w+)?$/i },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export function inferProviderFromRef(ref) {
|
|
54
|
+
if (!ref || typeof ref !== 'string') return null;
|
|
55
|
+
for (const { provider, pattern } of KNOWN_PROVIDER_PATTERNS) {
|
|
56
|
+
if (pattern.test(ref)) return provider;
|
|
57
|
+
}
|
|
58
|
+
const generic = /^([A-Z0-9]+)_API_KEY(?:_\w+)?$/i.exec(ref);
|
|
59
|
+
if (generic && generic[1]) {
|
|
60
|
+
const prov = generic[1].toLowerCase();
|
|
61
|
+
if (!/TOKEN|SECRET|WEBHOOK|PASSWORD|AUTH|SESSION|OAUTH|GITEA|KANBAN/i.test(prov)) {
|
|
62
|
+
return prov;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function discoverUnconfiguredKeys(ctx, runtimePools = []) {
|
|
69
|
+
const configuredRefs = new Set();
|
|
70
|
+
for (const p of runtimePools) {
|
|
71
|
+
for (const r of (p.refs || [])) configuredRefs.add(r);
|
|
72
|
+
for (const r of (p.keys || [])) configuredRefs.add(typeof r === 'string' ? r : r?.ref);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Also check DSH settings describe if available
|
|
76
|
+
const settings = ctx.get('settings');
|
|
77
|
+
if (settings && typeof settings.describe === 'function') {
|
|
78
|
+
try {
|
|
79
|
+
const desc = settings.describe({ redactSecrets: true })?.find?.((c) => c.ns === NS);
|
|
80
|
+
const providers = desc?.value?.providers ?? [];
|
|
81
|
+
for (const p of providers) {
|
|
82
|
+
for (const k of (p.keys || [])) {
|
|
83
|
+
configuredRefs.add(typeof k === 'string' ? k : k?.ref);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (_) { /* best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const candidates = new Map(); // ref -> { provider, source }
|
|
90
|
+
|
|
91
|
+
// 1. Process environment
|
|
92
|
+
for (const k of Object.keys(process.env)) {
|
|
93
|
+
if (configuredRefs.has(k)) continue;
|
|
94
|
+
const prov = inferProviderFromRef(k);
|
|
95
|
+
if (prov && process.env[k]?.trim()) {
|
|
96
|
+
candidates.set(k, { provider: prov, source: 'env' });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. ~/.dsh/.credentials.yaml
|
|
101
|
+
try {
|
|
102
|
+
const credPath = path.join(os.homedir(), '.dsh', '.credentials.yaml');
|
|
103
|
+
if (fs.existsSync(credPath)) {
|
|
104
|
+
const content = fs.readFileSync(credPath, 'utf8');
|
|
105
|
+
let inRefs = false;
|
|
106
|
+
for (const line of content.split('\n')) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (trimmed === 'refs:') {
|
|
109
|
+
inRefs = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (inRefs) {
|
|
113
|
+
if (line.startsWith(' ') && line.includes(':')) {
|
|
114
|
+
const refName = trimmed.split(':')[0].trim();
|
|
115
|
+
if (!configuredRefs.has(refName)) {
|
|
116
|
+
const prov = inferProviderFromRef(refName);
|
|
117
|
+
if (prov && !candidates.has(refName)) {
|
|
118
|
+
candidates.set(refName, { provider: prov, source: 'credentials' });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else if (!line.startsWith(' ') && trimmed.length > 0) {
|
|
122
|
+
inRefs = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (_) { /* best-effort */ }
|
|
128
|
+
|
|
129
|
+
const credentialsService = ctx.get('credentials');
|
|
130
|
+
const discovered = [];
|
|
131
|
+
|
|
132
|
+
for (const [ref, { provider, source }] of candidates) {
|
|
133
|
+
let tail = '';
|
|
134
|
+
try {
|
|
135
|
+
let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
|
|
136
|
+
let raw = hit?.value;
|
|
137
|
+
if (!raw) raw = envValue(ref);
|
|
138
|
+
if (raw) {
|
|
139
|
+
const plain = decryptSecret(raw);
|
|
140
|
+
tail = keyTail(plain);
|
|
141
|
+
}
|
|
142
|
+
} catch (_) { /* best-effort tail */ }
|
|
143
|
+
discovered.push({
|
|
144
|
+
provider,
|
|
145
|
+
ref,
|
|
146
|
+
keyTail: tail,
|
|
147
|
+
source,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return discovered;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
30
155
|
export function registerKeyRoutes(ctx, deps) {
|
|
31
156
|
|
|
32
157
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -300,4 +425,118 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
300
425
|
}), 'dsh-key-rotation: import route');
|
|
301
426
|
|
|
302
427
|
// Health for external panels (Beszel/Uptime)
|
|
428
|
+
|
|
429
|
+
// ── discover unconfigured keys route (#491) ──
|
|
430
|
+
ctx.effect(() => ctx.webServer.register({
|
|
431
|
+
kind: 'exact',
|
|
432
|
+
path: DISCOVER_PATH,
|
|
433
|
+
handler: async (req, res) => {
|
|
434
|
+
if (req.method !== 'GET') {
|
|
435
|
+
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
439
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: discover is local-only' } });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
const runtime = deps.buildRuntime?.() ?? {};
|
|
444
|
+
const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
|
|
445
|
+
json(res, 200, { ok: true, discovered });
|
|
446
|
+
} catch (e) {
|
|
447
|
+
json(res, 500, { error: { code: 'discover-failed', message: String(e?.message ?? e) } });
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
}), 'dsh-key-rotation: discover route');
|
|
451
|
+
|
|
452
|
+
// ── auto-import discovered keys into pools (#491) ──
|
|
453
|
+
ctx.effect(() => ctx.webServer.register({
|
|
454
|
+
kind: 'exact',
|
|
455
|
+
path: AUTO_IMPORT_PATH,
|
|
456
|
+
handler: async (req, res) => {
|
|
457
|
+
if (req.method !== 'POST') {
|
|
458
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
462
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: auto-import is local-only' } });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
let body = {};
|
|
466
|
+
try { body = await readJson(req); } catch (_) { body = {}; }
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const runtime = deps.buildRuntime?.() ?? {};
|
|
470
|
+
const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
|
|
471
|
+
const targetRefs = Array.isArray(body?.refs) && body.refs.length > 0
|
|
472
|
+
? new Set(body.refs)
|
|
473
|
+
: null;
|
|
474
|
+
|
|
475
|
+
const toImport = targetRefs
|
|
476
|
+
? discovered.filter((d) => targetRefs.has(d.ref))
|
|
477
|
+
: discovered;
|
|
478
|
+
|
|
479
|
+
if (toImport.length === 0) {
|
|
480
|
+
json(res, 200, { ok: true, importedCount: 0, message: 'No unassigned keys to import' });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const settings = ctx.get('settings');
|
|
485
|
+
if (!settings) {
|
|
486
|
+
json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const desc = settings.describe({ redactSecrets: true })?.find?.((c) => c.ns === NS);
|
|
490
|
+
if (!desc) throw new Error('Settings namespace is unavailable');
|
|
491
|
+
|
|
492
|
+
const curProviders = (desc.value?.providers ?? []).map((p) => ({
|
|
493
|
+
...p,
|
|
494
|
+
keys: Array.isArray(p.keys) ? [...p.keys] : [],
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
const providerMap = new Map();
|
|
498
|
+
for (const p of curProviders) {
|
|
499
|
+
if (p?.provider) providerMap.set(p.provider, p);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let importedCount = 0;
|
|
503
|
+
for (const item of toImport) {
|
|
504
|
+
const { provider, ref } = item;
|
|
505
|
+
let poolConfig = providerMap.get(provider);
|
|
506
|
+
if (!poolConfig) {
|
|
507
|
+
poolConfig = {
|
|
508
|
+
provider,
|
|
509
|
+
strategy: 'round-robin',
|
|
510
|
+
cooldownMs: 30000,
|
|
511
|
+
keys: [],
|
|
512
|
+
};
|
|
513
|
+
providerMap.set(provider, poolConfig);
|
|
514
|
+
}
|
|
515
|
+
const existingKeys = poolConfig.keys.map((k) => (typeof k === 'string' ? k : k?.ref));
|
|
516
|
+
if (!existingKeys.includes(ref)) {
|
|
517
|
+
poolConfig.keys.push(ref);
|
|
518
|
+
importedCount++;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const updatedProviders = [...providerMap.values()];
|
|
523
|
+
await mutateSettings(
|
|
524
|
+
settings,
|
|
525
|
+
NS,
|
|
526
|
+
[{ op: 'set', path: ['providers'], value: updatedProviders }],
|
|
527
|
+
body.expectedRevision ?? desc.revision
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
json(res, 200, {
|
|
531
|
+
ok: true,
|
|
532
|
+
importedCount,
|
|
533
|
+
providersCount: updatedProviders.length,
|
|
534
|
+
providers: updatedProviders,
|
|
535
|
+
});
|
|
536
|
+
} catch (e) {
|
|
537
|
+
json(res, 400, { error: { code: 'auto-import-failed', message: String(e?.message ?? e) } });
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
}), 'dsh-key-rotation: auto-import route');
|
|
541
|
+
|
|
303
542
|
}
|
package/lib/ops-paths.js
CHANGED
|
@@ -14,3 +14,6 @@ export const DIAG_PATH = '/dsh-key-rotation/diag';
|
|
|
14
14
|
export const TRACE_PATH = '/dsh-key-rotation/trace';
|
|
15
15
|
export const SIMULATE_PATH = '/dsh-key-rotation/simulate';
|
|
16
16
|
export const DRAIN_PATH = '/dsh-key-rotation/drain';
|
|
17
|
+
export const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
|
|
18
|
+
export const DISCOVER_PATH = '/dsh-key-rotation/discover';
|
|
19
|
+
export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
|
package/lib/ops-test.js
CHANGED
|
@@ -13,6 +13,7 @@ import { bestEffort } from './best-effort.js';
|
|
|
13
13
|
import {
|
|
14
14
|
TEST_PATH,
|
|
15
15
|
SANDBOX_CACHE_PATH,
|
|
16
|
+
TEST_MATRIX_PATH,
|
|
16
17
|
} from './ops-paths.js';
|
|
17
18
|
import { decryptSecret } from './crypto-storage.js';
|
|
18
19
|
|
|
@@ -114,4 +115,114 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
114
115
|
// #199 webhook-action: interactive webhook buttons call back here.
|
|
115
116
|
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
116
117
|
// cannot be same-origin, so a shared secret is the gate).
|
|
118
|
+
|
|
119
|
+
// ── test matrix route: batch probe all configured keys (#490) ──
|
|
120
|
+
ctx.effect(() => ctx.webServer.register({
|
|
121
|
+
kind: 'exact',
|
|
122
|
+
path: TEST_MATRIX_PATH,
|
|
123
|
+
handler: async (req, res) => {
|
|
124
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
125
|
+
json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
129
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test-matrix is local-only' } });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const base = ctx.get('credentials');
|
|
133
|
+
const runner = ensureSandboxRunner(ctx);
|
|
134
|
+
const runtime = deps.buildRuntime?.() ?? {};
|
|
135
|
+
const pools = runtime.pools || [];
|
|
136
|
+
|
|
137
|
+
const keyItems = [];
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for (const p of pools) {
|
|
140
|
+
const pName = p.provider ?? p.base ?? 'unknown';
|
|
141
|
+
for (const ref of (p.refs || [])) {
|
|
142
|
+
const id = `${pName}:${ref}`;
|
|
143
|
+
if (!seen.has(id)) {
|
|
144
|
+
seen.add(id);
|
|
145
|
+
keyItems.push({ provider: pName, ref });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const CONCURRENCY = 5;
|
|
151
|
+
const results = [];
|
|
152
|
+
let idx = 0;
|
|
153
|
+
|
|
154
|
+
async function worker() {
|
|
155
|
+
while (idx < keyItems.length) {
|
|
156
|
+
const item = keyItems[idx++];
|
|
157
|
+
if (!item) break;
|
|
158
|
+
const { provider, ref } = item;
|
|
159
|
+
try {
|
|
160
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
161
|
+
let rawValue = hit?.value;
|
|
162
|
+
if (!rawValue) {
|
|
163
|
+
const ev = envValue(ref);
|
|
164
|
+
if (ev !== undefined) rawValue = ev;
|
|
165
|
+
}
|
|
166
|
+
let effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
|
|
167
|
+
const tail = effectiveValue ? keyTail(effectiveValue) : '';
|
|
168
|
+
if (!effectiveValue) {
|
|
169
|
+
results.push({ provider, ref, keyTail: tail, ok: false, code: 'no-credential', latencyMs: 0 });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const probeRes = await runner.probeModels(ref, effectiveValue);
|
|
173
|
+
const cached = { ...probeRes, at: Date.now() };
|
|
174
|
+
lastTestCache.set(ref, cached);
|
|
175
|
+
if (cached.ok) {
|
|
176
|
+
for (const st of poolState.values()) {
|
|
177
|
+
st.failedUntil?.delete(ref);
|
|
178
|
+
st.failCounts?.delete(ref);
|
|
179
|
+
st.authFailCounts?.delete(ref);
|
|
180
|
+
st.brokenUntil?.delete(ref);
|
|
181
|
+
st.revokedRefs?.delete(ref);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
results.push({
|
|
185
|
+
provider,
|
|
186
|
+
ref,
|
|
187
|
+
keyTail: tail,
|
|
188
|
+
ok: cached.ok,
|
|
189
|
+
code: cached.code,
|
|
190
|
+
latencyMs: cached.latencyMs,
|
|
191
|
+
modelsCount: cached.modelsCount,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
results.push({
|
|
195
|
+
provider,
|
|
196
|
+
ref,
|
|
197
|
+
keyTail: '',
|
|
198
|
+
ok: false,
|
|
199
|
+
code: 'error',
|
|
200
|
+
error: String(err?.message ?? err),
|
|
201
|
+
latencyMs: 0,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const workers = [];
|
|
208
|
+
for (let i = 0; i < Math.min(CONCURRENCY, keyItems.length); i++) {
|
|
209
|
+
workers.push(worker());
|
|
210
|
+
}
|
|
211
|
+
await Promise.all(workers);
|
|
212
|
+
|
|
213
|
+
const total = results.length;
|
|
214
|
+
const healthy = results.filter((r) => r.ok).length;
|
|
215
|
+
const failed = total - healthy;
|
|
216
|
+
|
|
217
|
+
json(res, 200, {
|
|
218
|
+
ok: true,
|
|
219
|
+
at: Date.now(),
|
|
220
|
+
total,
|
|
221
|
+
healthy,
|
|
222
|
+
failed,
|
|
223
|
+
results,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
}), 'dsh-key-rotation: test matrix route');
|
|
227
|
+
|
|
117
228
|
}
|
package/lib/pool-state.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const MAP_FIELDS = [
|
|
4
4
|
'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
|
|
5
5
|
'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
|
|
6
|
-
'tokenUsage', 'rpmWindows', 'tpmWindows',
|
|
6
|
+
'tokenUsage', 'rpmWindows', 'tpmWindows', 'sessionSticky',
|
|
7
7
|
];
|
|
8
8
|
|
|
9
9
|
export function initializePoolState(state = {}) {
|
package/lib/pool.js
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
export const MAX_SESSION_STICKY_ENTRIES = 500;
|
|
2
|
+
|
|
3
|
+
export function setSessionSticky(pool, sessionId, candidate) {
|
|
4
|
+
if (!sessionId || !candidate || !pool?.state) return;
|
|
5
|
+
if (!(pool.state.sessionSticky instanceof Map)) {
|
|
6
|
+
pool.state.sessionSticky = new Map();
|
|
7
|
+
}
|
|
8
|
+
if (pool.state.sessionSticky.has(sessionId)) {
|
|
9
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
10
|
+
} else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
|
|
11
|
+
const oldest = pool.state.sessionSticky.keys().next().value;
|
|
12
|
+
if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
|
|
13
|
+
}
|
|
14
|
+
pool.state.sessionSticky.set(sessionId, candidate);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getSessionSticky(pool, sessionId) {
|
|
18
|
+
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
19
|
+
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clearSessionSticky(pool, sessionId) {
|
|
23
|
+
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
24
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
25
|
+
}
|
|
26
|
+
|
|
1
27
|
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
2
28
|
import { decryptSecret } from './crypto-storage.js';
|
|
3
29
|
import { bestEffort } from './best-effort.js';
|
|
@@ -412,7 +438,10 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
|
412
438
|
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
413
439
|
const list = refs.slice();
|
|
414
440
|
if (strategy === 'sticky') {
|
|
415
|
-
const last = deps.
|
|
441
|
+
const last = deps.sessionSticky ||
|
|
442
|
+
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
443
|
+
deps.lastUsed ||
|
|
444
|
+
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
416
445
|
if (last && list.includes(last)) {
|
|
417
446
|
const idx = list.indexOf(last);
|
|
418
447
|
if (idx > 0) {
|
package/lib/resolver.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// delegates to `original(ref)`.
|
|
10
10
|
|
|
11
11
|
import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
|
|
12
|
-
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
12
|
+
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict, getSessionSticky, setSessionSticky } from './pool.js';
|
|
13
13
|
import { decryptSecret } from './crypto-storage.js';
|
|
14
14
|
import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
|
|
15
15
|
import {
|
|
@@ -76,12 +76,13 @@ export function getProviderCost(provider, runtime, now = Date.now()) {
|
|
|
76
76
|
export function createResolver({
|
|
77
77
|
buildRuntime,
|
|
78
78
|
currentPool,
|
|
79
|
+
currentSession,
|
|
79
80
|
onPicked,
|
|
80
81
|
latencyHistogram,
|
|
81
82
|
concurrencyTracker,
|
|
82
83
|
now = Date.now,
|
|
83
84
|
}) {
|
|
84
|
-
return async function resolve(ref, original) {
|
|
85
|
+
return async function resolve(ref, original, resolveOpts) {
|
|
85
86
|
const runtime = buildRuntime();
|
|
86
87
|
// A ref may belong to several model pools at once, so the request-scoped
|
|
87
88
|
// pool that rotate() actually dispatched with decides ownership. A bare
|
|
@@ -116,9 +117,18 @@ export function createResolver({
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
120
|
+
const sessionId = resolveOpts?.sessionId ?? resolveOpts?.session ?? resolveOpts?.metadata?.sessionId ?? currentSession?.();
|
|
119
121
|
let list = pool.weightedRefs ?? pool.refs;
|
|
120
122
|
if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky') {
|
|
121
|
-
list = sortAttemptList(list, strategy, {
|
|
123
|
+
list = sortAttemptList(list, strategy, {
|
|
124
|
+
latencyHistogram,
|
|
125
|
+
concurrencyTracker,
|
|
126
|
+
weights: pool.weightsMap,
|
|
127
|
+
tiers: pool.tiers,
|
|
128
|
+
sessionId,
|
|
129
|
+
sessionSticky: sessionId ? getSessionSticky(pool, sessionId) : null,
|
|
130
|
+
lastUsed: pool.state.lastUsed,
|
|
131
|
+
});
|
|
122
132
|
}
|
|
123
133
|
// Fail closed: when this request is managed by a model pool that has local
|
|
124
134
|
// token budgets, an exhausted candidate may not be dispatched and the
|
|
@@ -127,7 +137,15 @@ export function createResolver({
|
|
|
127
137
|
const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
|
|
128
138
|
let start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
|
|
129
139
|
if (strategy === 'sticky') {
|
|
130
|
-
|
|
140
|
+
let preferred = null;
|
|
141
|
+
if (sessionId) {
|
|
142
|
+
const stick = getSessionSticky(pool, sessionId);
|
|
143
|
+
if (stick && list.includes(stick)) preferred = stick;
|
|
144
|
+
}
|
|
145
|
+
if (!preferred && pool.state.lastUsed && list.includes(pool.state.lastUsed)) {
|
|
146
|
+
preferred = pool.state.lastUsed;
|
|
147
|
+
}
|
|
148
|
+
const lastIdx = preferred ? list.indexOf(preferred) : -1;
|
|
131
149
|
start = lastIdx >= 0 ? lastIdx : (pool.state.pointer ?? 0);
|
|
132
150
|
}
|
|
133
151
|
if (pool.pinnedRef && list.includes(pool.pinnedRef)) {
|
|
@@ -229,14 +247,14 @@ export function createResolver({
|
|
|
229
247
|
try {
|
|
230
248
|
const hit = await original(candidate);
|
|
231
249
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
232
|
-
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
|
|
250
|
+
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined, sessionId);
|
|
233
251
|
return { ...hit, value: decryptSecret(hit.value) };
|
|
234
252
|
}
|
|
235
253
|
// No stored credential: fall back to the launching environment, which is
|
|
236
254
|
// how env-bootstrapped pools work (issue #7).
|
|
237
255
|
const envVal = envValue(candidate);
|
|
238
256
|
if (envVal !== undefined) {
|
|
239
|
-
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
|
|
257
|
+
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined, sessionId);
|
|
240
258
|
return { value: decryptSecret(envVal), source: 'env' };
|
|
241
259
|
}
|
|
242
260
|
} catch (err) {
|
|
@@ -268,8 +286,9 @@ export function createResolver({
|
|
|
268
286
|
}
|
|
269
287
|
|
|
270
288
|
/** Record a successful credential selection on the pool and request scope. */
|
|
271
|
-
function settle(pool, candidate, now, onPicked, concurrencyRef) {
|
|
289
|
+
function settle(pool, candidate, now, onPicked, concurrencyRef, sessionId) {
|
|
272
290
|
pool.state.lastUsed = candidate;
|
|
291
|
+
if (sessionId) setSessionSticky(pool, sessionId, candidate);
|
|
273
292
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
274
293
|
pool.state.lastUsedAt.set(candidate, now);
|
|
275
294
|
onPicked?.(pool, candidate, { concurrencyRef });
|
package/lib/rotate.js
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
isKeyBroken,
|
|
15
15
|
formatExhaustionMessage,
|
|
16
16
|
sortAttemptList,
|
|
17
|
+
getSessionSticky,
|
|
18
|
+
setSessionSticky,
|
|
19
|
+
clearSessionSticky,
|
|
17
20
|
} from './pool.js';
|
|
18
21
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
19
22
|
import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
|
|
@@ -104,7 +107,8 @@ export function createRotate(deps) {
|
|
|
104
107
|
let lastFailure = null;
|
|
105
108
|
const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
|
|
106
109
|
let activePermit = null;
|
|
107
|
-
const
|
|
110
|
+
const sessionId = options?.sessionId ?? options?.session ?? options?.metadata?.sessionId ?? options?.chatId ?? options?.conversationId;
|
|
111
|
+
const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now(), sessionId };
|
|
108
112
|
setRotateStartMs(reqStore.startMs);
|
|
109
113
|
const wallNow = Date.now();
|
|
110
114
|
const quotaManaged = hasModelQuotaConfig(pool);
|
|
@@ -155,7 +159,15 @@ export function createRotate(deps) {
|
|
|
155
159
|
}
|
|
156
160
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
157
161
|
if (strategy !== 'round-robin') {
|
|
158
|
-
attemptList = sortAttemptList(attemptList, strategy, {
|
|
162
|
+
attemptList = sortAttemptList(attemptList, strategy, {
|
|
163
|
+
latencyHistogram,
|
|
164
|
+
concurrencyTracker,
|
|
165
|
+
weights: pool.weightsMap,
|
|
166
|
+
tiers: pool.tiers,
|
|
167
|
+
sessionId,
|
|
168
|
+
sessionSticky: sessionId ? getSessionSticky(pool, sessionId) : null,
|
|
169
|
+
lastUsed: pool.state.lastUsed,
|
|
170
|
+
});
|
|
159
171
|
}
|
|
160
172
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
161
173
|
// #193 / #365: prefer least-loaded key within limit with weights
|
|
@@ -178,6 +190,7 @@ export function createRotate(deps) {
|
|
|
178
190
|
|
|
179
191
|
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
180
192
|
if (!targetRef) return;
|
|
193
|
+
if (sessionId) clearSessionSticky(pool, sessionId);
|
|
181
194
|
if (errCode === LOCAL_QUOTA_CODE) return;
|
|
182
195
|
const _retry = parseRetryAfter(errMsg);
|
|
183
196
|
const _base = pool.cooldownMs ?? cooldownMs;
|
|
@@ -444,6 +457,7 @@ export function createRotate(deps) {
|
|
|
444
457
|
// #336: record success for the active key upon successful stream finish
|
|
445
458
|
if (activeRef) {
|
|
446
459
|
recordSuccess(pool, activeRef, now());
|
|
460
|
+
if (sessionId) setSessionSticky(pool, sessionId, activeRef);
|
|
447
461
|
}
|
|
448
462
|
if (activeRef) {
|
|
449
463
|
const usedTokens = extractUsageTokens(chunk?.usage ?? chunk?.metadata?.usage);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.62",
|
|
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": [
|