@goodandready/dsh-key-rotation 0.8.56 → 0.8.58
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 +33 -0
- package/lib/client.js +114 -0
- package/lib/index.js +15 -4
- package/lib/lifecycle.js +99 -0
- package/lib/notify-events.js +30 -0
- package/lib/ops-status.js +3 -0
- package/lib/pool-builder.js +9 -1
- package/lib/pool.js +8 -0
- package/lib/resolver.js +25 -7
- package/lib/rotate.js +22 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.58 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Early-Warning Notification Dispatch (#477)**:
|
|
7
|
+
- Connected `notifyEarlyWarning` during key penalization in `lib/rotate.js` when healthy pool capacity drops to or below `earlyWarningKeyThreshold`.
|
|
8
|
+
- Added export `notifyEarlyWarning` in `lib/index.js` and wired dependency through `createRotate`.
|
|
9
|
+
- **Proactive Health Probing Logging (#478)**:
|
|
10
|
+
- Replaced empty catch block in `lib/lifecycle.js` `setupProactiveHealthProbeEffect` with structured debug logging (`logger?.debug`), eliminating silent swallowed exceptions.
|
|
11
|
+
- **Key Drain Lifecycle Teardown (#479)**:
|
|
12
|
+
- Registered `setupKeyDrainEffect` in `lib/lifecycle.js` to ensure module-level `drainingKeys` map is wiped cleanly on plugin teardown/dispose via `clearAllDraining()`.
|
|
13
|
+
- **Quota Reset Window Schema Validation (#480)**:
|
|
14
|
+
- Connected `QUOTA_WINDOW_TYPES` into `SettingsSchema` via `Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc')`.
|
|
15
|
+
- Connected `isBlockedUntilReset` in `lib/ops-status.js` to accurately expose calendar-based reset lock status.
|
|
16
|
+
|
|
17
|
+
## 0.8.57 - 2026-10-12
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Tier-based / Cost-aware Routing (`tier-priority`) (#475)**:
|
|
21
|
+
- Added `tier-priority` routing strategy and optional per-key `tiers` configuration.
|
|
22
|
+
- Automatically sorts and routes requests through tier 1 keys first, gracefully falling back to tier 2 only when primary tier keys are cooling down or exhausted.
|
|
23
|
+
- **Proactive Health Probing (#475)**:
|
|
24
|
+
- Added opt-in `proactiveHealthProbing` and `proactiveProbeIntervalMs` background lifecycle effect.
|
|
25
|
+
- Automatically sends lightweight probe pings for keys in cooldown or quarantine to restore healthy status proactively without waiting for user failover.
|
|
26
|
+
- **RPM / TPM Micro-Throttling (#475)**:
|
|
27
|
+
- Added opt-in `microThrottling` and `microThrottleMaxMs` options.
|
|
28
|
+
- If a key hits rate limits but the sliding window resets within `microThrottleMaxMs` (default 1000ms), resolves inline micro-sleep rather than failing over prematurely.
|
|
29
|
+
- **Early-Warning Alerts (#475)**:
|
|
30
|
+
- Added `earlyWarningNotify` and `earlyWarningKeyThreshold` options.
|
|
31
|
+
- Automatically dispatches webhook alerts when available healthy keys drop below threshold (e.g. 25%) before pool-wide exhaustion.
|
|
32
|
+
- **Interactive Routing Simulator UI (#475)**:
|
|
33
|
+
- Integrated an interactive simulator section into Web Settings UI backed by `/dsh-key-rotation/simulate`.
|
|
34
|
+
- Enables playground testing of rotation, synthetic rate limits, and failure fallback traces without sending live upstream requests.
|
|
35
|
+
|
|
3
36
|
## 0.8.56 - 2026-10-12
|
|
4
37
|
|
|
5
38
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -44,6 +44,19 @@ window.__ModuleLoader__.load({
|
|
|
44
44
|
'header.sub': 'Automated multi-key round-robin rotation, 429/5xx exponential backoff cooldowns, circuit breaker failover protection, and credential security (~/.dsh/.credentials.yaml).',
|
|
45
45
|
'header.pools_badge': 'pools active',
|
|
46
46
|
'header.keys_badge': 'keys configured',
|
|
47
|
+
simulatorTitle: 'Interactive Routing Simulator',
|
|
48
|
+
simulatorDesc: 'Test routing strategies and visualize failover trace across key pools without sending real upstream requests.',
|
|
49
|
+
simulatorRun: 'Run Simulation',
|
|
50
|
+
simulatorRunning: 'Simulating...',
|
|
51
|
+
simulatorSelectProvider: 'Provider',
|
|
52
|
+
simulatorSelectModel: 'Model (optional)',
|
|
53
|
+
simulatorMaxAttempts: 'Max Attempts',
|
|
54
|
+
simulatorEvent: 'Simulated Outcome',
|
|
55
|
+
simulatorEventSuccess: 'All keys succeed',
|
|
56
|
+
simulatorEventRateLimit: 'Key 1 Rate Limited (429)',
|
|
57
|
+
simulatorEventServer500: 'Key 1 Internal Error (500)',
|
|
58
|
+
simulatorResults: 'Simulation Trace',
|
|
59
|
+
simulatorNoResults: 'Run a simulation to view the candidate execution trace.',
|
|
47
60
|
sectionStats: 'Pool Health & Telemetry',
|
|
48
61
|
sectionStatsDesc: 'Real-time telemetry of active key pools, healthy credentials, and failover status.',
|
|
49
62
|
sectionFailover: 'Failover Triggers & Error Codes',
|
|
@@ -1103,6 +1116,50 @@ window.__ModuleLoader__.load({
|
|
|
1103
1116
|
const [search, setSearch] = React.useState('');
|
|
1104
1117
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
|
1105
1118
|
const [confirmModal, setConfirmModal] = React.useState(null);
|
|
1119
|
+
// Simulator State (#475)
|
|
1120
|
+
const [simProvider, setSimProvider] = React.useState('');
|
|
1121
|
+
const [simModel, setSimModel] = React.useState('');
|
|
1122
|
+
const [simAttempts, setSimAttempts] = React.useState(3);
|
|
1123
|
+
const [simScenario, setSimScenario] = React.useState('success');
|
|
1124
|
+
const [simRunning, setSimRunning] = React.useState(false);
|
|
1125
|
+
const [simResult, setSimResult] = React.useState(null);
|
|
1126
|
+
const [simError, setSimError] = React.useState('');
|
|
1127
|
+
|
|
1128
|
+
const runSimulator = async () => {
|
|
1129
|
+
const activeProv = simProvider || (providersList[0]?.provider || '');
|
|
1130
|
+
if (!activeProv) return;
|
|
1131
|
+
setSimRunning(true);
|
|
1132
|
+
setSimError('');
|
|
1133
|
+
try {
|
|
1134
|
+
const events = [];
|
|
1135
|
+
if (simScenario === 'rate_limit') {
|
|
1136
|
+
events.push({ code: '429', message: 'Rate limit reached' });
|
|
1137
|
+
} else if (simScenario === 'error_500') {
|
|
1138
|
+
events.push({ code: '500', message: 'Internal Server Error' });
|
|
1139
|
+
}
|
|
1140
|
+
const res = await fetch('/dsh-key-rotation/simulate', {
|
|
1141
|
+
method: 'POST',
|
|
1142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1143
|
+
body: JSON.stringify({
|
|
1144
|
+
provider: activeProv,
|
|
1145
|
+
model: simModel,
|
|
1146
|
+
maxAttempts: Number(simAttempts) || 3,
|
|
1147
|
+
events,
|
|
1148
|
+
}),
|
|
1149
|
+
});
|
|
1150
|
+
const data = await res.json();
|
|
1151
|
+
if (data && data.ok) {
|
|
1152
|
+
setSimResult(data.simulation);
|
|
1153
|
+
} else {
|
|
1154
|
+
setSimError(data?.error?.message || 'Simulation error');
|
|
1155
|
+
}
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
setSimError(String(err?.message || err));
|
|
1158
|
+
} finally {
|
|
1159
|
+
setSimRunning(false);
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1106
1163
|
const [optimisticReset, setOptimisticReset] = React.useState({});
|
|
1107
1164
|
const [selected, setSelected] = React.useState(new Set());
|
|
1108
1165
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
@@ -2445,6 +2502,62 @@ window.__ModuleLoader__.load({
|
|
|
2445
2502
|
)
|
|
2446
2503
|
);
|
|
2447
2504
|
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
const configuredProviders = (draft?.providers ?? state.value?.providers ?? []);
|
|
2508
|
+
const simulatorSection = h('div', { className: 'krot-section', style: { marginTop: '14px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '8px', padding: '14px' } },
|
|
2509
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' } },
|
|
2510
|
+
h('div', null,
|
|
2511
|
+
h('span', { style: { fontWeight: 600, fontSize: '13px' } }, '🧪 ' + t('simulatorTitle')),
|
|
2512
|
+
h('div', { className: 'krot-hint', style: { fontSize: '11px', marginTop: '2px' } }, t('simulatorDesc'))
|
|
2513
|
+
)
|
|
2514
|
+
),
|
|
2515
|
+
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '10px' } },
|
|
2516
|
+
h('select', {
|
|
2517
|
+
className: 'krot-input',
|
|
2518
|
+
style: { width: '140px' },
|
|
2519
|
+
value: simProvider || (configuredProviders[0]?.provider || ''),
|
|
2520
|
+
onChange: (e) => setSimProvider(e.target.value),
|
|
2521
|
+
}, configuredProviders.map((p) => h('option', { key: p.provider, value: p.provider }, p.provider))),
|
|
2522
|
+
h('input', {
|
|
2523
|
+
type: 'text',
|
|
2524
|
+
className: 'krot-input',
|
|
2525
|
+
style: { width: '120px' },
|
|
2526
|
+
placeholder: t('simulatorSelectModel'),
|
|
2527
|
+
value: simModel,
|
|
2528
|
+
onChange: (e) => setSimModel(e.target.value),
|
|
2529
|
+
}),
|
|
2530
|
+
h('select', {
|
|
2531
|
+
className: 'krot-input',
|
|
2532
|
+
style: { width: '180px' },
|
|
2533
|
+
value: simScenario,
|
|
2534
|
+
onChange: (e) => setSimScenario(e.target.value),
|
|
2535
|
+
},
|
|
2536
|
+
h('option', { value: 'success' }, t('simulatorEventSuccess')),
|
|
2537
|
+
h('option', { value: 'rate_limit' }, t('simulatorEventRateLimit')),
|
|
2538
|
+
h('option', { value: 'error_500' }, t('simulatorEventServer500')),
|
|
2539
|
+
),
|
|
2540
|
+
btn(simRunning ? t('simulatorRunning') : t('simulatorRun'), runSimulator, {
|
|
2541
|
+
primary: true,
|
|
2542
|
+
disabled: simRunning,
|
|
2543
|
+
})
|
|
2544
|
+
),
|
|
2545
|
+
simError ? h('div', { className: 'krot-warn', style: { marginTop: '8px', color: 'var(--dsw-alias-error)' } }, simError) : null,
|
|
2546
|
+
simResult ? h('div', { style: { marginTop: '12px', background: 'var(--dsw-alias-bg-hover)', padding: '10px', borderRadius: '6px' } },
|
|
2547
|
+
h('div', { style: { fontWeight: 600, fontSize: '12px', marginBottom: '6px' } }, t('simulatorResults') + ': ' + (simResult.status || 'DONE')),
|
|
2548
|
+
h('div', { style: { fontSize: '11px', display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
2549
|
+
Array.isArray(simResult.steps) ? simResult.steps.map((st, idx) =>
|
|
2550
|
+
h('div', { key: idx, style: { display: 'flex', gap: '8px', fontFamily: 'monospace' } },
|
|
2551
|
+
h('span', { style: { color: 'var(--dsw-alias-text-sub)' } }, '[' + (idx + 1) + ']'),
|
|
2552
|
+
h('span', { style: { fontWeight: 500 } }, st.ref || '-'),
|
|
2553
|
+
h('span', { style: { color: st.status === 'OK' ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)' } }, st.status),
|
|
2554
|
+
h('span', { style: { opacity: 0.8 } }, st.reason || st.error || '')
|
|
2555
|
+
)
|
|
2556
|
+
) : null
|
|
2557
|
+
)
|
|
2558
|
+
) : null
|
|
2559
|
+
);
|
|
2560
|
+
|
|
2448
2561
|
const backupSection = h('div', { className: 'krot-section-card' },
|
|
2449
2562
|
h('div', { className: 'krot-section-title' },
|
|
2450
2563
|
h('span', null, '💾 ' + t('sectionBackup')),
|
|
@@ -2555,6 +2668,7 @@ window.__ModuleLoader__.load({
|
|
|
2555
2668
|
failoverSection,
|
|
2556
2669
|
timingSection,
|
|
2557
2670
|
backupSection,
|
|
2671
|
+
simulatorSection,
|
|
2558
2672
|
),
|
|
2559
2673
|
alerts.length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, alerts) : null,
|
|
2560
2674
|
h('div', { className: 'krot-foot', style: { marginTop: '6px', paddingTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
package/lib/index.js
CHANGED
|
@@ -31,7 +31,8 @@ import { NotifyQueue } from './notify-queue.js';
|
|
|
31
31
|
import { classifyFailure } from './error-taxonomy.js';
|
|
32
32
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
33
33
|
import { registerPluginUpdater } from './plugin-updater.js';
|
|
34
|
-
import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
34
|
+
import { notifySwitch, notifyExhaustion, notifyEarlyWarning, pushEvent } from './notify-events.js';
|
|
35
|
+
import { QUOTA_WINDOW_TYPES } from './quota-window.js';
|
|
35
36
|
import { getLogger } from './logger.js';
|
|
36
37
|
import { createSandboxService } from './sandbox-service.js';
|
|
37
38
|
import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
|
|
@@ -44,12 +45,12 @@ import {
|
|
|
44
45
|
formatModelQuotaExhaustion,
|
|
45
46
|
} from './model-quota.js';
|
|
46
47
|
import { LOCAL_QUOTA_CODE } from './rotate.js';
|
|
47
|
-
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
|
|
48
|
+
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect, setupKeyDrainEffect } from './lifecycle.js';
|
|
48
49
|
|
|
49
50
|
export const name = '@goodandready/dsh-key-rotation';
|
|
50
51
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
51
52
|
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
52
|
-
export { notifySwitch, notifyExhaustion };
|
|
53
|
+
export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
53
54
|
|
|
54
55
|
const NS = 'dsh-key-rotation';
|
|
55
56
|
|
|
@@ -88,7 +89,7 @@ const SettingsSchema = Schema.object({
|
|
|
88
89
|
model: Schema.string(),
|
|
89
90
|
})).default([]),
|
|
90
91
|
quotaResetWindow: Schema.object({
|
|
91
|
-
type: Schema.
|
|
92
|
+
type: Schema.union(QUOTA_WINDOW_TYPES).default('midnight_utc'),
|
|
92
93
|
hour: Schema.number().default(0),
|
|
93
94
|
}),
|
|
94
95
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
@@ -110,12 +111,19 @@ const SettingsSchema = Schema.object({
|
|
|
110
111
|
persistenceEnabled: Schema.boolean().default(true),
|
|
111
112
|
persistencePath: Schema.string().default(''),
|
|
112
113
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
114
|
+
proactiveHealthProbing: Schema.boolean().default(false),
|
|
115
|
+
proactiveProbeIntervalMs: Schema.number().default(60000),
|
|
116
|
+
microThrottling: Schema.boolean().default(false),
|
|
117
|
+
microThrottleMaxMs: Schema.number().default(1000),
|
|
118
|
+
earlyWarningNotify: Schema.boolean().default(false),
|
|
119
|
+
earlyWarningKeyThreshold: Schema.number().default(0.25),
|
|
113
120
|
warnBelowHealthy: Schema.number().default(0),
|
|
114
121
|
latencySloMs: Schema.number().default(0),
|
|
115
122
|
providers: Schema.array(Schema.object({
|
|
116
123
|
provider: Schema.string().required(),
|
|
117
124
|
keys: Schema.array(Schema.string()).default([]),
|
|
118
125
|
weights: Schema.array(Schema.number()).default([]),
|
|
126
|
+
tiers: Schema.array(Schema.number()).default([]),
|
|
119
127
|
paused: Schema.array(Schema.boolean()).default([]),
|
|
120
128
|
revoked: Schema.array(Schema.boolean()).default([]),
|
|
121
129
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
@@ -198,6 +206,8 @@ export function apply(ctx, config = {}) {
|
|
|
198
206
|
ensureSandboxRunner(ctx);
|
|
199
207
|
|
|
200
208
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
209
|
+
setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
210
|
+
setupKeyDrainEffect(ctx);
|
|
201
211
|
|
|
202
212
|
const poolState = new Map();
|
|
203
213
|
const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
|
|
@@ -413,6 +423,7 @@ export function apply(ctx, config = {}) {
|
|
|
413
423
|
pushEvent,
|
|
414
424
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
415
425
|
notifyExhaustion: (runtime, pool, options) => notifyExhaustion(runtime, pool, options, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
426
|
+
notifyEarlyWarning: (runtime, pool, info) => notifyEarlyWarning(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
416
427
|
recordLatency,
|
|
417
428
|
latencyHistogram,
|
|
418
429
|
concurrencyTracker,
|
package/lib/lifecycle.js
CHANGED
|
@@ -4,6 +4,7 @@ import { decryptSecret } from './crypto-storage.js';
|
|
|
4
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { envValue } from './pool.js';
|
|
7
|
+
import { clearAllDraining } from './key-drain.js';
|
|
7
8
|
|
|
8
9
|
export function setupIdleHealEffect(ctx, getConfig, buildRuntime, timers = globalThis) {
|
|
9
10
|
return ctx.effect(() => {
|
|
@@ -156,3 +157,101 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
156
157
|
|
|
157
158
|
return { schedulePersist, persistenceSnapshot };
|
|
158
159
|
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger, timers = globalThis) {
|
|
164
|
+
return ctx.effect(() => {
|
|
165
|
+
let timer;
|
|
166
|
+
let running = false;
|
|
167
|
+
let disposed = false;
|
|
168
|
+
|
|
169
|
+
const run = async () => {
|
|
170
|
+
if (running || disposed) return;
|
|
171
|
+
running = true;
|
|
172
|
+
try {
|
|
173
|
+
const c = getConfig();
|
|
174
|
+
if (!c || c.proactiveHealthProbing !== true) return;
|
|
175
|
+
const { pools } = buildRuntime();
|
|
176
|
+
const runner = ensureSandboxRunner(ctx);
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
for (const pool of (pools || [])) {
|
|
179
|
+
if (!pool || !pool.state) continue;
|
|
180
|
+
const fu = pool.state.failedUntil;
|
|
181
|
+
const bu = pool.state.brokenUntil;
|
|
182
|
+
const candidates = new Set();
|
|
183
|
+
if (fu) {
|
|
184
|
+
for (const [ref, until] of fu.entries()) {
|
|
185
|
+
if (until > now && until !== Infinity) candidates.add(ref);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (bu) {
|
|
189
|
+
for (const [ref, until] of bu.entries()) {
|
|
190
|
+
if (until > now) candidates.add(ref);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const ref of candidates) {
|
|
194
|
+
if (disposed) break;
|
|
195
|
+
try {
|
|
196
|
+
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
197
|
+
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
198
|
+
const hit = await resolve?.call(credentials, ref);
|
|
199
|
+
const raw = typeof hit?.value === 'string' && hit.value.length > 0 ? hit.value : envValue(ref);
|
|
200
|
+
const val = raw ? decryptSecret(raw) : raw;
|
|
201
|
+
if (!val || typeof val !== 'string' || val.startsWith('enc:v1:')) continue;
|
|
202
|
+
const res = await runner.probeModels(ref, val);
|
|
203
|
+
if (res && res.ok) {
|
|
204
|
+
pool.state.failedUntil?.delete(ref);
|
|
205
|
+
pool.state.brokenUntil?.delete(ref);
|
|
206
|
+
pool.state.failCounts?.delete(ref);
|
|
207
|
+
if (Array.isArray(pool.state.events)) {
|
|
208
|
+
pool.state.events.push({ at: Date.now(), ref, reason: 'proactive-probe-heal', cooldownMs: 0, type: 'heal' });
|
|
209
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
210
|
+
}
|
|
211
|
+
logger?.warn?.(`[dsh-key-rotation] proactive-probe: restored key ${ref} in pool ${pool.base}`);
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logger?.debug?.(`[dsh-key-rotation] proactive-probe failed for ${ref}: ${err?.message || err}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (e) {
|
|
219
|
+
logger?.warn?.('[dsh-key-rotation] proactive probe failed', e);
|
|
220
|
+
} finally {
|
|
221
|
+
running = false;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const configure = () => {
|
|
226
|
+
if (disposed) return;
|
|
227
|
+
const c = getConfig();
|
|
228
|
+
const intervalMs = (c && Number.isFinite(c.proactiveProbeIntervalMs) && c.proactiveProbeIntervalMs >= 1000)
|
|
229
|
+
? c.proactiveProbeIntervalMs : 60000;
|
|
230
|
+
if (timer !== undefined) timers.clearInterval(timer);
|
|
231
|
+
if (c?.proactiveHealthProbing === true) {
|
|
232
|
+
timer = timers.setInterval(run, intervalMs);
|
|
233
|
+
timer?.unref?.();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const changed = () => queueMicrotask(configure);
|
|
238
|
+
const off = [
|
|
239
|
+
typeof ctx?.on === 'function' ? ctx.on('settings/document-updated', changed) : () => {},
|
|
240
|
+
typeof ctx?.on === 'function' ? ctx.on('loader/volatile-update', changed) : () => {},
|
|
241
|
+
];
|
|
242
|
+
configure();
|
|
243
|
+
return () => {
|
|
244
|
+
disposed = true;
|
|
245
|
+
if (timer !== undefined) timers.clearInterval(timer);
|
|
246
|
+
for (const stop of off) stop();
|
|
247
|
+
};
|
|
248
|
+
}, 'dsh-key-rotation: proactive health probing');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function setupKeyDrainEffect(ctx) {
|
|
252
|
+
return ctx.effect(() => {
|
|
253
|
+
return () => {
|
|
254
|
+
clearAllDraining();
|
|
255
|
+
};
|
|
256
|
+
}, 'dsh-key-rotation: clear draining keys on teardown');
|
|
257
|
+
}
|
package/lib/notify-events.js
CHANGED
|
@@ -64,3 +64,33 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
|
64
64
|
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
|
|
68
|
+
const earlyWarningNotifiedAt = new Map();
|
|
69
|
+
|
|
70
|
+
export function notifyEarlyWarning(runtime, pool, info = {}, hooks = { webhookSender: defaultWebhookSender, now: () => Date.now() }) {
|
|
71
|
+
if (!runtime?.notifyWebhook || runtime.earlyWarningNotify !== true) return;
|
|
72
|
+
const provider = info.provider || pool?.provider || pool?.base || 'unknown';
|
|
73
|
+
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
74
|
+
const last = earlyWarningNotifiedAt.get(provider) ?? 0;
|
|
75
|
+
const now = hooks.now();
|
|
76
|
+
if (now - last < throttle) return;
|
|
77
|
+
earlyWarningNotifiedAt.set(provider, now);
|
|
78
|
+
|
|
79
|
+
const availableCount = info.availableCount ?? 0;
|
|
80
|
+
const totalCount = info.totalCount ?? (pool?.refs?.length || 1);
|
|
81
|
+
const ratio = totalCount > 0 ? (availableCount / totalCount) : 0;
|
|
82
|
+
const send = hooks.notifyQueue
|
|
83
|
+
? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
|
|
84
|
+
: (url, payload) => hooks.webhookSender.send(url, payload);
|
|
85
|
+
|
|
86
|
+
send(runtime.notifyWebhook, {
|
|
87
|
+
title: `Early warning: Key pool degraded (${provider})`,
|
|
88
|
+
text: `Pool ${provider} has only ${availableCount}/${totalCount} (${Math.round(ratio * 100)}%) active keys remaining.`,
|
|
89
|
+
provider,
|
|
90
|
+
kind: 'early_warning',
|
|
91
|
+
availableCount,
|
|
92
|
+
totalCount,
|
|
93
|
+
ratio,
|
|
94
|
+
at: now,
|
|
95
|
+
});
|
|
96
|
+
}
|
package/lib/ops-status.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
isLoopbackAddress,
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { bucketInfo, tpmInfo } from './bucket.js';
|
|
18
|
+
import { isBlockedUntilReset } from './quota-window.js';
|
|
18
19
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
19
20
|
import {
|
|
20
21
|
STATUS_PATH,
|
|
@@ -92,6 +93,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
92
93
|
/* describe is optional — the card falls back to editable */
|
|
93
94
|
}
|
|
94
95
|
const until = pool.state.failedUntil?.get(ref);
|
|
96
|
+
const quotaWin = pool.state.quotaWindows?.get(ref);
|
|
95
97
|
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
96
98
|
const activeModelPools = runtime?.index ? modelPoolsForRef(runtime.index, ref) : [];
|
|
97
99
|
keys.push({
|
|
@@ -102,6 +104,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
102
104
|
writable,
|
|
103
105
|
active: pool.state.lastUsed === ref,
|
|
104
106
|
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
107
|
+
blockedUntilReset: isBlockedUntilReset(until, quotaWin, now),
|
|
105
108
|
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
106
109
|
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
107
110
|
// TPM capacity snapshot (null when tpmLimit is off)
|
package/lib/pool-builder.js
CHANGED
|
@@ -18,6 +18,7 @@ export function buildPoolItem({
|
|
|
18
18
|
base,
|
|
19
19
|
keys,
|
|
20
20
|
weights,
|
|
21
|
+
tiers,
|
|
21
22
|
paused,
|
|
22
23
|
revoked,
|
|
23
24
|
poolCooldown,
|
|
@@ -83,7 +84,13 @@ export function buildPoolItem({
|
|
|
83
84
|
if (revoked.has(refs[i])) parsedRevoked.add(refs[i]);
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
+
|
|
88
|
+
const tiersMap = {};
|
|
89
|
+
for (let i = 0; i < refs.length; i++) {
|
|
90
|
+
const val = Array.isArray(tiers) ? tiers[indexed[i].index] : (tiers && typeof tiers === object ? tiers[refs[i]] : undefined);
|
|
91
|
+
tiersMap[refs[i]] = Number.isFinite(val) && val >= 1 ? Math.floor(val) : 1;
|
|
92
|
+
}
|
|
93
|
+
const weightsMap = {};
|
|
87
94
|
for (let i = 0; i < refs.length; i++) {
|
|
88
95
|
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
89
96
|
}
|
|
@@ -113,6 +120,7 @@ export function buildPoolItem({
|
|
|
113
120
|
refs,
|
|
114
121
|
weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
115
122
|
weightsMap,
|
|
123
|
+
tiers: tiersMap,
|
|
116
124
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
117
125
|
pausedRefs: parsedPaused,
|
|
118
126
|
revokedRefs: parsedRevoked,
|
package/lib/pool.js
CHANGED
|
@@ -409,6 +409,14 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
409
409
|
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
410
410
|
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
411
411
|
const list = refs.slice();
|
|
412
|
+
if (strategy === 'tier-priority') {
|
|
413
|
+
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
414
|
+
return list.sort((a, b) => {
|
|
415
|
+
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
416
|
+
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
417
|
+
return ta - tb;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
412
420
|
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
413
421
|
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
414
422
|
return list.sort((a, b) => {
|
package/lib/resolver.js
CHANGED
|
@@ -118,7 +118,7 @@ export function createResolver({
|
|
|
118
118
|
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
119
119
|
let list = pool.weightedRefs ?? pool.refs;
|
|
120
120
|
if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
|
|
121
|
-
list = sortAttemptList(list, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
121
|
+
list = sortAttemptList(list, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers });
|
|
122
122
|
}
|
|
123
123
|
// Fail closed: when this request is managed by a model pool that has local
|
|
124
124
|
// token budgets, an exhausted candidate may not be dispatched and the
|
|
@@ -148,15 +148,26 @@ export function createResolver({
|
|
|
148
148
|
if (candidateExp !== undefined && at >= candidateExp) continue;
|
|
149
149
|
// Local model token budget: a spent credential is simply not a candidate.
|
|
150
150
|
if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
|
|
151
|
+
const microThrottling = pool.microThrottling ?? runtime.microThrottling ?? false;
|
|
152
|
+
const microThrottleMaxMs = pool.microThrottleMaxMs ?? runtime.microThrottleMaxMs ?? 1000;
|
|
153
|
+
|
|
151
154
|
const rpmLimit = pool.rpmLimit ?? 0;
|
|
152
155
|
if (rpmLimit > 0) {
|
|
153
156
|
if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
|
|
154
157
|
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, at)) {
|
|
155
158
|
const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, at);
|
|
156
|
-
if (
|
|
157
|
-
|
|
159
|
+
if (microThrottling && waitMs > 0 && waitMs <= microThrottleMaxMs) {
|
|
160
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
161
|
+
// re-check after micro-sleep
|
|
162
|
+
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now())) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
|
|
167
|
+
pool.state.failedUntil.set(candidate, at + waitMs);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
158
170
|
}
|
|
159
|
-
continue;
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
const tpmLimit = pool.tpmLimit ?? 0;
|
|
@@ -164,10 +175,17 @@ export function createResolver({
|
|
|
164
175
|
if (!pool.state.tpmWindows) pool.state.tpmWindows = new Map();
|
|
165
176
|
if (!tpmAllow(pool.state.tpmWindows, candidate, tpmLimit, at)) {
|
|
166
177
|
const waitMs = tpmRetryMs(pool.state.tpmWindows, candidate, tpmLimit, at);
|
|
167
|
-
if (
|
|
168
|
-
|
|
178
|
+
if (microThrottling && waitMs > 0 && waitMs <= microThrottleMaxMs) {
|
|
179
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
180
|
+
if (!tpmAllow(pool.state.tpmWindows, candidate, tpmLimit, now())) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
|
|
185
|
+
pool.state.failedUntil.set(candidate, at + waitMs);
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
169
188
|
}
|
|
170
|
-
continue;
|
|
171
189
|
}
|
|
172
190
|
}
|
|
173
191
|
if (pool.perHour) {
|
package/lib/rotate.js
CHANGED
|
@@ -19,6 +19,7 @@ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
|
19
19
|
import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
|
|
20
20
|
import { createSelectionTrace } from './selection-trace.js';
|
|
21
21
|
import { isKeyDraining } from './key-drain.js';
|
|
22
|
+
import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
|
|
22
23
|
import { nextQuotaReset, poolResetAt } from './quota-window.js';
|
|
23
24
|
import { tpmRecord } from './bucket.js';
|
|
24
25
|
import {
|
|
@@ -64,6 +65,7 @@ export function createRotate(deps) {
|
|
|
64
65
|
pushEvent,
|
|
65
66
|
notifySwitch,
|
|
66
67
|
notifyExhaustion,
|
|
68
|
+
notifyEarlyWarning,
|
|
67
69
|
recordLatency,
|
|
68
70
|
latencyHistogram,
|
|
69
71
|
concurrencyTracker,
|
|
@@ -77,6 +79,7 @@ export function createRotate(deps) {
|
|
|
77
79
|
now = nowMono,
|
|
78
80
|
} = deps;
|
|
79
81
|
|
|
82
|
+
const effectiveNotifyEarlyWarning = notifyEarlyWarning ?? defaultNotifyEarlyWarning;
|
|
80
83
|
const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
|
|
81
84
|
|
|
82
85
|
function rotate(options, pool) {
|
|
@@ -152,7 +155,7 @@ export function createRotate(deps) {
|
|
|
152
155
|
}
|
|
153
156
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
154
157
|
if (strategy !== 'round-robin') {
|
|
155
|
-
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
158
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers });
|
|
156
159
|
}
|
|
157
160
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
158
161
|
// #193 / #365: prefer least-loaded key within limit with weights
|
|
@@ -204,6 +207,24 @@ export function createRotate(deps) {
|
|
|
204
207
|
if (typeof schedulePersist === 'function') {
|
|
205
208
|
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
206
209
|
}
|
|
210
|
+
if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
|
|
211
|
+
const total = pool.refs?.length || 1;
|
|
212
|
+
let available = 0;
|
|
213
|
+
const at = now();
|
|
214
|
+
for (const r of (pool.refs || [])) {
|
|
215
|
+
if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
|
|
216
|
+
available++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
|
|
220
|
+
if (total > 0 && (available / total) <= threshold) {
|
|
221
|
+
effectiveNotifyEarlyWarning(runtime0, pool, {
|
|
222
|
+
provider: options.provider,
|
|
223
|
+
availableCount: available,
|
|
224
|
+
totalCount: total,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
207
228
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
208
229
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
209
230
|
const _cStr = String(errCode ?? '');
|
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.58",
|
|
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": [
|