@goodandready/dsh-key-rotation 0.8.61 → 0.8.63
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 +24 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +223 -3
- package/lib/index.js +4 -1
- package/lib/ops-keys.js +239 -0
- package/lib/ops-paths.js +3 -0
- package/lib/ops-status.js +2 -1
- 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,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.63 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Configurable Switch to Disable Chat Header Status Chip (#493 / GitHub #25)**:
|
|
7
|
+
- Added `showChatHeaderChip` boolean setting (defaults to `true` for 100% backward compatibility) in `SettingsSchema` and runtime configuration.
|
|
8
|
+
- Added user-facing toggle in the Settings card ("Chat Header Status Chip" / "聊天头部状态按钮") under rotation settings.
|
|
9
|
+
- `KeyRotationHeaderChip` (`conversation.session.header.utilities`) dynamically subscribes to settings and checks server health state, cleanly returning `null` when disabled without taking up conversational header toolbar space.
|
|
10
|
+
- Additive field `showChatHeaderChip` exposed in `/dsh-key-rotation/status` and `/dsh-key-rotation/health`.
|
|
11
|
+
|
|
12
|
+
## 0.8.62 - 2026-10-16
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Session-Aware Sticky Routing Affinity for Prompt Caching (#489)**:
|
|
16
|
+
- Enhanced `sticky` routing strategy with LRU session affinity (`pool.state.sessionSticky`, capped at 500 active sessions).
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- **1-Click Test Matrix Dashboard (#490)**:
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- **Zero-Config Pool Auto-Discovery & 1-Click Import (#491)**:
|
|
23
|
+
- Added `/dsh-key-rotation/discover` and `/dsh-key-rotation/auto-import` endpoints in `lib/ops-keys.js`.
|
|
24
|
+
- 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.
|
|
25
|
+
- Added smart auto-discovery banner to the Settings Card with 1-click import into provider pools.
|
|
26
|
+
|
|
3
27
|
## 0.8.61 - 2026-10-15
|
|
4
28
|
|
|
5
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -378,6 +378,7 @@ dsh-key-rotation:
|
|
|
378
378
|
| `circuitBreakerOpenMs` | `number` | `30000` | How long the circuit stays open (ms). |
|
|
379
379
|
| `circuitBreakerHalfOpenProbes` | `number` | `1` | Probe requests allowed in half-open state. |
|
|
380
380
|
| `verboseLogging` | `boolean` | `false` | Per-request rotation logs (noisy; off by default). |
|
|
381
|
+
| `showChatHeaderChip` | `boolean` | `true` | Show or hide the pool status button in conversation header toolbar (#25). |
|
|
381
382
|
| `concurrencyLimit` | `number` | `0` (disabled) | Max concurrent in-flight streams per key (0 = unlimited). |
|
|
382
383
|
| `quotaResetWindow` | `object` | `null` | Calendar reset alignment for both provider-reported quota cooldowns and local per-model token budgets (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
|
|
383
384
|
| `cascade` | `array` | `[]` | Fallback provider chain when primary pool is completely exhausted. |
|
package/README.ru.md
CHANGED
|
@@ -409,6 +409,7 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
409
409
|
| `circuitBreakerOpenMs` | number | `30000` | Длительность open, мс |
|
|
410
410
|
| `circuitBreakerHalfOpenProbes` | number | `1` | Проб в half-open |
|
|
411
411
|
| `verboseLogging` | boolean | `false` | Подробные логи rotation (шумно) |
|
|
412
|
+
| `showChatHeaderChip` | boolean | `true` | Показывать кнопку статуса пула в шапке чата (#25) |
|
|
412
413
|
|
|
413
414
|
Status API (v0.8.0): у каждого провайдера `circuit`, в корне ответа `meta.expectedClones` и `meta.notifyQueue`.
|
|
414
415
|
|
package/README.zh.md
CHANGED
|
@@ -378,3 +378,4 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
378
378
|
| `circuitBreakerThreshold` | number | `5` | 连续失败阈值 |
|
|
379
379
|
| `circuitBreakerOpenMs` | number | `30000` | 打开时长 ms |
|
|
380
380
|
| `circuitBreakerHalfOpenProbes` | number | `1` | 半开探测次数 |
|
|
381
|
+
| `showChatHeaderChip` | boolean | `true` | 在会话界面顶部工具栏显示密钥池状态按钮 (#25) |
|
package/lib/client.js
CHANGED
|
@@ -224,6 +224,8 @@ window.__ModuleLoader__.load({
|
|
|
224
224
|
routingStrategySticky: 'Sticky (preserves prompt cache)',
|
|
225
225
|
proactiveGuardLabel: 'Proactive Rate-Limit Guard',
|
|
226
226
|
proactiveGuardHint: 'Pre-emptively pause keys when remaining quota or Retry-After header indicates limit before hitting 429',
|
|
227
|
+
showChatHeaderChipLabel: 'Chat Header Status Chip',
|
|
228
|
+
showChatHeaderChipDesc: 'Show pool status chip in conversation header',
|
|
227
229
|
selfHealingLabel: 'Auto-Unbreak Interval (min)',
|
|
228
230
|
selfHealingHint: 'Periodic free model check to revive broken keys (0 to disable)',
|
|
229
231
|
updateCheck: 'Check for updates',
|
|
@@ -235,6 +237,19 @@ window.__ModuleLoader__.load({
|
|
|
235
237
|
updateFailed: 'Update failed',
|
|
236
238
|
currentVersion: 'Current version',
|
|
237
239
|
latestVersion: 'Latest version',
|
|
240
|
+
testAllKeys: 'Test All Keys',
|
|
241
|
+
testingMatrix: 'Testing Keys…',
|
|
242
|
+
matrixModalTitle: '⚡ Key Test Matrix',
|
|
243
|
+
matrixModalDesc: 'Parallel probe of all configured credentials across all provider pools.',
|
|
244
|
+
matrixSummary: '{healthy}/{total} keys operational',
|
|
245
|
+
matrixLatency: 'Latency',
|
|
246
|
+
matrixStatus: 'Status',
|
|
247
|
+
matrixClose: 'Close',
|
|
248
|
+
discoverBannerTitle: '⚡ Unassigned API Keys Discovered',
|
|
249
|
+
discoverBannerDesc: 'Found {n} credentials in your environment (~/.dsh/.credentials.yaml / env) not yet assigned to any rotation pool.',
|
|
250
|
+
autoImportBtn: '1-Click Auto-Import',
|
|
251
|
+
autoImportSuccess: 'Imported {n} keys into rotation pools',
|
|
252
|
+
autoImporting: 'Importing…',
|
|
238
253
|
};
|
|
239
254
|
|
|
240
255
|
const zh = {
|
|
@@ -409,6 +424,8 @@ window.__ModuleLoader__.load({
|
|
|
409
424
|
routingStrategySticky: '粘性模式 (优先保持提示词缓存)',
|
|
410
425
|
proactiveGuardLabel: '前瞻性限流防护',
|
|
411
426
|
proactiveGuardHint: '当响应中的剩余配额或 Retry-After 头提示即将超限时,预先冷却密钥以防触发 429',
|
|
427
|
+
showChatHeaderChipLabel: '聊天头部状态按钮',
|
|
428
|
+
showChatHeaderChipDesc: '在会话界面顶部工具栏显示密钥池状态按钮',
|
|
412
429
|
selfHealingLabel: '自愈检查间隔 (分钟)',
|
|
413
430
|
selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
|
|
414
431
|
updateCheck: '检查更新',
|
|
@@ -420,6 +437,19 @@ window.__ModuleLoader__.load({
|
|
|
420
437
|
updateFailed: '更新失败',
|
|
421
438
|
currentVersion: '当前版本',
|
|
422
439
|
latestVersion: '最新版本',
|
|
440
|
+
testAllKeys: '批量测试所有密钥',
|
|
441
|
+
testingMatrix: '正在测试密钥…',
|
|
442
|
+
matrixModalTitle: '⚡ 密钥测试矩阵',
|
|
443
|
+
matrixModalDesc: '并行探测所有提供商密钥池中配置的凭据。',
|
|
444
|
+
matrixSummary: '{healthy}/{total} 个密钥正常可用',
|
|
445
|
+
matrixLatency: '延迟',
|
|
446
|
+
matrixStatus: '状态',
|
|
447
|
+
matrixClose: '关闭',
|
|
448
|
+
discoverBannerTitle: '⚡ 发现未分配的 API 密钥',
|
|
449
|
+
discoverBannerDesc: '在当前环境 (~/.dsh/.credentials.yaml / env) 中发现 {n} 个未分配给轮换池的密钥。',
|
|
450
|
+
autoImportBtn: '一键自动导入',
|
|
451
|
+
autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
|
|
452
|
+
autoImporting: '正在导入…',
|
|
423
453
|
};
|
|
424
454
|
|
|
425
455
|
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
@@ -1127,6 +1157,73 @@ window.__ModuleLoader__.load({
|
|
|
1127
1157
|
const [simResult, setSimResult] = React.useState(null);
|
|
1128
1158
|
const [simError, setSimError] = React.useState('');
|
|
1129
1159
|
|
|
1160
|
+
// Auto-Discovery State (#491)
|
|
1161
|
+
const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
|
|
1162
|
+
const [autoImporting, setAutoImporting] = React.useState(false);
|
|
1163
|
+
const [autoImportMsg, setAutoImportMsg] = React.useState('');
|
|
1164
|
+
|
|
1165
|
+
// Test Matrix State (#490)
|
|
1166
|
+
const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
|
|
1167
|
+
const [testMatrixResults, setTestMatrixResults] = React.useState(null);
|
|
1168
|
+
const [testMatrixModal, setTestMatrixModal] = React.useState(false);
|
|
1169
|
+
|
|
1170
|
+
React.useEffect(() => {
|
|
1171
|
+
let mounted = true;
|
|
1172
|
+
fetch('/dsh-key-rotation/discover', {
|
|
1173
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
1174
|
+
})
|
|
1175
|
+
.then((r) => r.json())
|
|
1176
|
+
.then((data) => {
|
|
1177
|
+
if (mounted && data?.ok && Array.isArray(data.discovered)) {
|
|
1178
|
+
setDiscoveredKeys(data.discovered);
|
|
1179
|
+
}
|
|
1180
|
+
})
|
|
1181
|
+
.catch((e) => { void e; });
|
|
1182
|
+
return () => { mounted = false; };
|
|
1183
|
+
}, []);
|
|
1184
|
+
|
|
1185
|
+
const runTestMatrix = async () => {
|
|
1186
|
+
setTestMatrixRunning(true);
|
|
1187
|
+
setTestMatrixModal(true);
|
|
1188
|
+
try {
|
|
1189
|
+
const res = await fetch('/dsh-key-rotation/test-matrix', {
|
|
1190
|
+
method: 'POST',
|
|
1191
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
|
|
1192
|
+
});
|
|
1193
|
+
const data = await res.json();
|
|
1194
|
+
if (data?.ok) {
|
|
1195
|
+
setTestMatrixResults(data);
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
setTestMatrixResults({ ok: false, failed: 1, total: 1, results: [{ provider: 'all', ref: 'matrix', ok: false, code: 'error', error: String(err?.message ?? err) }] });
|
|
1199
|
+
} finally {
|
|
1200
|
+
setTestMatrixRunning(false);
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const runAutoImport = async () => {
|
|
1205
|
+
setAutoImporting(true);
|
|
1206
|
+
setAutoImportMsg('');
|
|
1207
|
+
try {
|
|
1208
|
+
const res = await fetch('/dsh-key-rotation/auto-import', {
|
|
1209
|
+
method: 'POST',
|
|
1210
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1211
|
+
body: JSON.stringify({}),
|
|
1212
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined,
|
|
1213
|
+
});
|
|
1214
|
+
const data = await res.json();
|
|
1215
|
+
if (data?.ok) {
|
|
1216
|
+
setAutoImportMsg(t('autoImportSuccess').replace('{n}', String(data.importedCount || 0)));
|
|
1217
|
+
setDiscoveredKeys([]);
|
|
1218
|
+
void load();
|
|
1219
|
+
}
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
setAutoImportMsg(String(err?.message ?? err));
|
|
1222
|
+
} finally {
|
|
1223
|
+
setAutoImporting(false);
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1130
1227
|
const runSimulator = async () => {
|
|
1131
1228
|
const activeProv = simProvider || (providersList[0]?.provider || '');
|
|
1132
1229
|
if (!activeProv) return;
|
|
@@ -2448,6 +2545,7 @@ window.__ModuleLoader__.load({
|
|
|
2448
2545
|
h('div', { className: 'krot-keys' }, providerRows),
|
|
2449
2546
|
h('div', { className: 'krot-foot', style: { marginTop: '10px' } },
|
|
2450
2547
|
btn(t('addProvider'), addProvider, { primary: false }),
|
|
2548
|
+
btn('⚡ ' + (testMatrixRunning ? t('testingMatrix') : t('testAllKeys')), runTestMatrix, { disabled: testMatrixRunning || totalKeysCount === 0 }),
|
|
2451
2549
|
noProviders
|
|
2452
2550
|
),
|
|
2453
2551
|
h(UpdaterSection, { t })
|
|
@@ -2502,6 +2600,16 @@ window.__ModuleLoader__.load({
|
|
|
2502
2600
|
}),
|
|
2503
2601
|
h('span', { key: 'label', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
|
|
2504
2602
|
]))
|
|
2603
|
+
),
|
|
2604
|
+
h('div', { className: 'krot-grid-2', style: { marginTop: '10px' } },
|
|
2605
|
+
field(t('showChatHeaderChipLabel'), h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', height: '36px', cursor: 'pointer' } }, [
|
|
2606
|
+
h('input', {
|
|
2607
|
+
key: 'input', type: 'checkbox',
|
|
2608
|
+
checked: val.showChatHeaderChip ?? true,
|
|
2609
|
+
onChange: (e) => setField((cur) => ({ ...cur, showChatHeaderChip: e.target.checked })),
|
|
2610
|
+
}),
|
|
2611
|
+
h('span', { key: 'label', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('showChatHeaderChipDesc')),
|
|
2612
|
+
]))
|
|
2505
2613
|
)
|
|
2506
2614
|
);
|
|
2507
2615
|
|
|
@@ -2661,10 +2769,105 @@ window.__ModuleLoader__.load({
|
|
|
2661
2769
|
h('div', { className: 'krot-page-sub' }, t('header.sub'))
|
|
2662
2770
|
);
|
|
2663
2771
|
|
|
2772
|
+
const discoveryBanner = (discoveredKeys.length > 0 || autoImportMsg) ? h('div', {
|
|
2773
|
+
className: 'krot-section-card',
|
|
2774
|
+
style: {
|
|
2775
|
+
background: 'var(--dsw-alias-bg-hover)',
|
|
2776
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2777
|
+
marginBottom: '12px',
|
|
2778
|
+
padding: '12px 14px',
|
|
2779
|
+
display: 'flex',
|
|
2780
|
+
flexDirection: 'column',
|
|
2781
|
+
gap: '8px',
|
|
2782
|
+
}
|
|
2783
|
+
},
|
|
2784
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
2785
|
+
h('div', null,
|
|
2786
|
+
h('span', { style: { fontWeight: 600, fontSize: '13px' } }, t('discoverBannerTitle')),
|
|
2787
|
+
h('div', { className: 'krot-hint', style: { fontSize: '11px', marginTop: '2px' } },
|
|
2788
|
+
autoImportMsg || t('discoverBannerDesc').replace('{n}', String(discoveredKeys.length))
|
|
2789
|
+
),
|
|
2790
|
+
),
|
|
2791
|
+
discoveredKeys.length > 0 ? btn(autoImporting ? t('autoImporting') : t('autoImportBtn'), runAutoImport, {
|
|
2792
|
+
primary: true,
|
|
2793
|
+
disabled: autoImporting,
|
|
2794
|
+
}) : null
|
|
2795
|
+
),
|
|
2796
|
+
discoveredKeys.length > 0 ? h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '4px' } },
|
|
2797
|
+
discoveredKeys.slice(0, 10).map((d) => h('span', {
|
|
2798
|
+
key: d.ref,
|
|
2799
|
+
className: 'krot-badge',
|
|
2800
|
+
style: { fontSize: '10px', background: 'var(--dsw-alias-bg-layer-3)' }
|
|
2801
|
+
}, `${d.provider}: ${d.ref} ${d.keyTail ? '(' + d.keyTail + ')' : ''}`))
|
|
2802
|
+
) : null
|
|
2803
|
+
) : null;
|
|
2804
|
+
|
|
2805
|
+
const testMatrixDialog = testMatrixModal ? h('div', {
|
|
2806
|
+
className: 'krot-modal-backdrop',
|
|
2807
|
+
onClick: () => setTestMatrixModal(false),
|
|
2808
|
+
},
|
|
2809
|
+
h('div', {
|
|
2810
|
+
className: 'krot-modal-card',
|
|
2811
|
+
role: 'dialog',
|
|
2812
|
+
'aria-modal': 'true',
|
|
2813
|
+
onClick: (e) => e.stopPropagation(),
|
|
2814
|
+
style: {
|
|
2815
|
+
maxWidth: '560px',
|
|
2816
|
+
width: '90vw',
|
|
2817
|
+
maxHeight: '80vh',
|
|
2818
|
+
display: 'flex',
|
|
2819
|
+
flexDirection: 'column',
|
|
2820
|
+
gap: '12px',
|
|
2821
|
+
}
|
|
2822
|
+
},
|
|
2823
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
2824
|
+
h('span', { style: { fontWeight: 600, fontSize: '15px' } }, t('matrixModalTitle')),
|
|
2825
|
+
btn('✕', () => setTestMatrixModal(false), { className: 'krot-btn-sub' })
|
|
2826
|
+
),
|
|
2827
|
+
h('div', { className: 'krot-hint', style: { fontSize: '12px' } }, t('matrixModalDesc')),
|
|
2828
|
+
testMatrixRunning ? h('div', { style: { textAlign: 'center', padding: '20px' } }, t('testingMatrix')) : null,
|
|
2829
|
+
testMatrixResults ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', maxHeight: '50vh' } },
|
|
2830
|
+
h('div', { style: { fontWeight: 600, fontSize: '13px' } },
|
|
2831
|
+
t('matrixSummary').replace('{healthy}', String(testMatrixResults.healthy)).replace('{total}', String(testMatrixResults.total))
|
|
2832
|
+
),
|
|
2833
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
2834
|
+
(testMatrixResults.results || []).map((r, i) => h('div', {
|
|
2835
|
+
key: i,
|
|
2836
|
+
style: {
|
|
2837
|
+
display: 'flex',
|
|
2838
|
+
justifyContent: 'space-between',
|
|
2839
|
+
alignItems: 'center',
|
|
2840
|
+
padding: '6px 10px',
|
|
2841
|
+
borderRadius: '6px',
|
|
2842
|
+
background: 'var(--dsw-alias-bg-hover)',
|
|
2843
|
+
fontSize: '12px',
|
|
2844
|
+
}
|
|
2845
|
+
},
|
|
2846
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
2847
|
+
h('span', { style: { fontWeight: 500 } }, r.provider),
|
|
2848
|
+
h('span', { style: { fontFamily: 'monospace' } }, r.ref),
|
|
2849
|
+
r.keyTail ? h('span', { style: { color: 'var(--dsw-alias-text-sub)', fontSize: '11px' } }, `(${r.keyTail})`) : null
|
|
2850
|
+
),
|
|
2851
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
2852
|
+
h('span', { style: { color: 'var(--dsw-alias-text-sub)' } }, `${r.latencyMs || 0}ms`),
|
|
2853
|
+
h('span', {
|
|
2854
|
+
className: 'krot-badge ' + (r.ok ? 'krot-badge-ok' : 'krot-badge-bad')
|
|
2855
|
+
}, r.ok ? 'OK' : (r.code || 'FAIL'))
|
|
2856
|
+
)
|
|
2857
|
+
))
|
|
2858
|
+
)
|
|
2859
|
+
) : null,
|
|
2860
|
+
h('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: '8px' } },
|
|
2861
|
+
btn(t('matrixClose'), () => setTestMatrixModal(false), { primary: true })
|
|
2862
|
+
)
|
|
2863
|
+
)
|
|
2864
|
+
) : null;
|
|
2664
2865
|
return h('div', { className: 'krot', 'aria-busy': saving || importing },
|
|
2665
2866
|
confirmModalEl,
|
|
2867
|
+
testMatrixDialog,
|
|
2666
2868
|
headerSection,
|
|
2667
2869
|
statsSection,
|
|
2870
|
+
discoveryBanner,
|
|
2668
2871
|
h('fieldset', { disabled: saving || importing || !state.writable,
|
|
2669
2872
|
style: { border: 0, padding: 0, margin: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '14px' } },
|
|
2670
2873
|
poolsSection,
|
|
@@ -2697,6 +2900,7 @@ window.__ModuleLoader__.load({
|
|
|
2697
2900
|
const t = resolveT(props);
|
|
2698
2901
|
const [snap, setSnap] = React.useState(null);
|
|
2699
2902
|
const [open, setOpen] = React.useState(false);
|
|
2903
|
+
const [, setTick] = React.useState(0);
|
|
2700
2904
|
const ref = React.useRef(null);
|
|
2701
2905
|
|
|
2702
2906
|
React.useEffect(() => {
|
|
@@ -2706,7 +2910,7 @@ window.__ModuleLoader__.load({
|
|
|
2706
2910
|
fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
|
|
2707
2911
|
.then((r) => (r.ok ? r.json() : null))
|
|
2708
2912
|
.then((d) => { if (alive) setSnap(d); })
|
|
2709
|
-
.catch(() => {});
|
|
2913
|
+
.catch((e) => { void e; });
|
|
2710
2914
|
};
|
|
2711
2915
|
load();
|
|
2712
2916
|
const id = setInterval(load, 5000);
|
|
@@ -2719,6 +2923,13 @@ window.__ModuleLoader__.load({
|
|
|
2719
2923
|
};
|
|
2720
2924
|
}, []);
|
|
2721
2925
|
|
|
2926
|
+
React.useEffect(() => {
|
|
2927
|
+
if (!props?.settingsSource?.subscribe) return;
|
|
2928
|
+
return props.settingsSource.subscribe(() => {
|
|
2929
|
+
setTick((c) => c + 1);
|
|
2930
|
+
});
|
|
2931
|
+
}, [props?.settingsSource]);
|
|
2932
|
+
|
|
2722
2933
|
React.useEffect(() => {
|
|
2723
2934
|
if (!open) return;
|
|
2724
2935
|
const onDocClick = (e) => {
|
|
@@ -2728,6 +2939,16 @@ window.__ModuleLoader__.load({
|
|
|
2728
2939
|
return () => document.removeEventListener('click', onDocClick);
|
|
2729
2940
|
}, [open]);
|
|
2730
2941
|
|
|
2942
|
+
let settingsVal = null;
|
|
2943
|
+
try {
|
|
2944
|
+
const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
|
|
2945
|
+
settingsVal = sSnap?.value;
|
|
2946
|
+
} catch (e) {
|
|
2947
|
+
void e;
|
|
2948
|
+
}
|
|
2949
|
+
if (settingsVal && settingsVal.showChatHeaderChip === false) return null;
|
|
2950
|
+
if (snap && snap.showChatHeaderChip === false) return null;
|
|
2951
|
+
|
|
2731
2952
|
const poolsObj = (snap && snap.pools) || {};
|
|
2732
2953
|
const poolEntries = Object.entries(poolsObj);
|
|
2733
2954
|
const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
|
|
@@ -2763,7 +2984,6 @@ window.__ModuleLoader__.load({
|
|
|
2763
2984
|
) : null
|
|
2764
2985
|
);
|
|
2765
2986
|
}
|
|
2766
|
-
|
|
2767
2987
|
function apply(ctx) {
|
|
2768
2988
|
const settingsSource = createSettingsSource(ctx);
|
|
2769
2989
|
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
@@ -2774,7 +2994,7 @@ window.__ModuleLoader__.load({
|
|
|
2774
2994
|
try {
|
|
2775
2995
|
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
2776
2996
|
ctx.slots.register(
|
|
2777
|
-
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS },
|
|
2997
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS, inject: () => ({ ctx, settingsSource }) },
|
|
2778
2998
|
KeyRotationHeaderChip,
|
|
2779
2999
|
));
|
|
2780
3000
|
} catch { /* slot not available in this build */ }
|
package/lib/index.js
CHANGED
|
@@ -119,6 +119,7 @@ const SettingsSchema = Schema.object({
|
|
|
119
119
|
earlyWarningKeyThreshold: Schema.number().default(0.25),
|
|
120
120
|
warnBelowHealthy: Schema.number().default(0),
|
|
121
121
|
latencySloMs: Schema.number().default(0),
|
|
122
|
+
showChatHeaderChip: Schema.boolean().default(true),
|
|
122
123
|
providers: Schema.array(Schema.object({
|
|
123
124
|
provider: Schema.string().required(),
|
|
124
125
|
keys: Schema.array(Schema.string()).default([]),
|
|
@@ -350,6 +351,7 @@ export function apply(ctx, config = {}) {
|
|
|
350
351
|
proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
|
|
351
352
|
selfHealingIntervalMinutes: cfg.selfHealingIntervalMinutes ?? 30,
|
|
352
353
|
routingStrategy: cfg.routingStrategy ?? 'round-robin',
|
|
354
|
+
showChatHeaderChip: cfg.showChatHeaderChip !== false,
|
|
353
355
|
switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
|
|
354
356
|
warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
|
|
355
357
|
index, pools,
|
|
@@ -374,6 +376,7 @@ export function apply(ctx, config = {}) {
|
|
|
374
376
|
const resolve = createResolver({
|
|
375
377
|
buildRuntime,
|
|
376
378
|
currentPool: () => dispatchStorage.getStore()?.pool,
|
|
379
|
+
currentSession: () => dispatchStorage.getStore()?.sessionId,
|
|
377
380
|
onPicked: (pool, candidate, meta) => {
|
|
378
381
|
const store = dispatchStorage.getStore();
|
|
379
382
|
if (store && store.pool === pool) {
|
|
@@ -384,7 +387,7 @@ export function apply(ctx, config = {}) {
|
|
|
384
387
|
latencyHistogram,
|
|
385
388
|
concurrencyTracker,
|
|
386
389
|
});
|
|
387
|
-
credentials.resolve = async (ref) => resolve(ref, original);
|
|
390
|
+
credentials.resolve = async (ref, opts) => resolve(ref, original, opts);
|
|
388
391
|
credentials.__dshKeyRotationPatched = true;
|
|
389
392
|
return () => {
|
|
390
393
|
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-status.js
CHANGED
|
@@ -178,6 +178,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
178
178
|
}
|
|
179
179
|
json(res, 200, sanitizeSnapshot({
|
|
180
180
|
providers,
|
|
181
|
+
showChatHeaderChip: runtime.showChatHeaderChip !== false,
|
|
181
182
|
// #266/#263 operational extras (additive)
|
|
182
183
|
meta: {
|
|
183
184
|
expectedClones: [...(runtime.expectedClones ?? [])],
|
|
@@ -248,7 +249,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
const isDegraded = exhaustedAny || circuitOpenAny;
|
|
251
|
-
json(res, 200, { status: isDegraded ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
|
|
252
|
+
json(res, 200, { status: isDegraded ? 'degraded' : 'ok', showChatHeaderChip: runtimeH.showChatHeaderChip !== false, pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
|
|
252
253
|
},
|
|
253
254
|
}), 'dsh-key-rotation: health');
|
|
254
255
|
|
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.63",
|
|
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": [
|