@goodandready/dsh-key-rotation 0.8.56 → 0.8.57
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 +19 -0
- package/lib/client.js +114 -0
- package/lib/index.js +9 -1
- package/lib/lifecycle.js +88 -0
- package/lib/notify-events.js +30 -0
- package/lib/pool-builder.js +9 -1
- package/lib/pool.js +8 -0
- package/lib/resolver.js +25 -7
- package/lib/rotate.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.57 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Tier-based / Cost-aware Routing (`tier-priority`) (#475)**:
|
|
7
|
+
- Added `tier-priority` routing strategy and optional per-key `tiers` configuration.
|
|
8
|
+
- 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.
|
|
9
|
+
- **Proactive Health Probing (#475)**:
|
|
10
|
+
- Added opt-in `proactiveHealthProbing` and `proactiveProbeIntervalMs` background lifecycle effect.
|
|
11
|
+
- Automatically sends lightweight probe pings for keys in cooldown or quarantine to restore healthy status proactively without waiting for user failover.
|
|
12
|
+
- **RPM / TPM Micro-Throttling (#475)**:
|
|
13
|
+
- Added opt-in `microThrottling` and `microThrottleMaxMs` options.
|
|
14
|
+
- If a key hits rate limits but the sliding window resets within `microThrottleMaxMs` (default 1000ms), resolves inline micro-sleep rather than failing over prematurely.
|
|
15
|
+
- **Early-Warning Alerts (#475)**:
|
|
16
|
+
- Added `earlyWarningNotify` and `earlyWarningKeyThreshold` options.
|
|
17
|
+
- Automatically dispatches webhook alerts when available healthy keys drop below threshold (e.g. 25%) before pool-wide exhaustion.
|
|
18
|
+
- **Interactive Routing Simulator UI (#475)**:
|
|
19
|
+
- Integrated an interactive simulator section into Web Settings UI backed by `/dsh-key-rotation/simulate`.
|
|
20
|
+
- Enables playground testing of rotation, synthetic rate limits, and failure fallback traces without sending live upstream requests.
|
|
21
|
+
|
|
3
22
|
## 0.8.56 - 2026-10-12
|
|
4
23
|
|
|
5
24
|
### 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
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
formatModelQuotaExhaustion,
|
|
45
45
|
} from './model-quota.js';
|
|
46
46
|
import { LOCAL_QUOTA_CODE } from './rotate.js';
|
|
47
|
-
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
|
|
47
|
+
import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect } from './lifecycle.js';
|
|
48
48
|
|
|
49
49
|
export const name = '@goodandready/dsh-key-rotation';
|
|
50
50
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
@@ -110,12 +110,19 @@ const SettingsSchema = Schema.object({
|
|
|
110
110
|
persistenceEnabled: Schema.boolean().default(true),
|
|
111
111
|
persistencePath: Schema.string().default(''),
|
|
112
112
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
113
|
+
proactiveHealthProbing: Schema.boolean().default(false),
|
|
114
|
+
proactiveProbeIntervalMs: Schema.number().default(60000),
|
|
115
|
+
microThrottling: Schema.boolean().default(false),
|
|
116
|
+
microThrottleMaxMs: Schema.number().default(1000),
|
|
117
|
+
earlyWarningNotify: Schema.boolean().default(false),
|
|
118
|
+
earlyWarningKeyThreshold: Schema.number().default(0.25),
|
|
113
119
|
warnBelowHealthy: Schema.number().default(0),
|
|
114
120
|
latencySloMs: Schema.number().default(0),
|
|
115
121
|
providers: Schema.array(Schema.object({
|
|
116
122
|
provider: Schema.string().required(),
|
|
117
123
|
keys: Schema.array(Schema.string()).default([]),
|
|
118
124
|
weights: Schema.array(Schema.number()).default([]),
|
|
125
|
+
tiers: Schema.array(Schema.number()).default([]),
|
|
119
126
|
paused: Schema.array(Schema.boolean()).default([]),
|
|
120
127
|
revoked: Schema.array(Schema.boolean()).default([]),
|
|
121
128
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
@@ -198,6 +205,7 @@ export function apply(ctx, config = {}) {
|
|
|
198
205
|
ensureSandboxRunner(ctx);
|
|
199
206
|
|
|
200
207
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
208
|
+
setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
201
209
|
|
|
202
210
|
const poolState = new Map();
|
|
203
211
|
const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
|
package/lib/lifecycle.js
CHANGED
|
@@ -156,3 +156,91 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
156
156
|
|
|
157
157
|
return { schedulePersist, persistenceSnapshot };
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger, timers = globalThis) {
|
|
163
|
+
return ctx.effect(() => {
|
|
164
|
+
let timer;
|
|
165
|
+
let running = false;
|
|
166
|
+
let disposed = false;
|
|
167
|
+
|
|
168
|
+
const run = async () => {
|
|
169
|
+
if (running || disposed) return;
|
|
170
|
+
running = true;
|
|
171
|
+
try {
|
|
172
|
+
const c = getConfig();
|
|
173
|
+
if (!c || c.proactiveHealthProbing !== true) return;
|
|
174
|
+
const { pools } = buildRuntime();
|
|
175
|
+
const runner = ensureSandboxRunner(ctx);
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
for (const pool of (pools || [])) {
|
|
178
|
+
if (!pool || !pool.state) continue;
|
|
179
|
+
const fu = pool.state.failedUntil;
|
|
180
|
+
const bu = pool.state.brokenUntil;
|
|
181
|
+
const candidates = new Set();
|
|
182
|
+
if (fu) {
|
|
183
|
+
for (const [ref, until] of fu.entries()) {
|
|
184
|
+
if (until > now && until !== Infinity) candidates.add(ref);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (bu) {
|
|
188
|
+
for (const [ref, until] of bu.entries()) {
|
|
189
|
+
if (until > now) candidates.add(ref);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const ref of candidates) {
|
|
193
|
+
if (disposed) break;
|
|
194
|
+
try {
|
|
195
|
+
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
196
|
+
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
197
|
+
const hit = await resolve?.call(credentials, ref);
|
|
198
|
+
const raw = typeof hit?.value === 'string' && hit.value.length > 0 ? hit.value : envValue(ref);
|
|
199
|
+
const val = raw ? decryptSecret(raw) : raw;
|
|
200
|
+
if (!val || typeof val !== 'string' || val.startsWith('enc:v1:')) continue;
|
|
201
|
+
const res = await runner.probeModels(ref, val);
|
|
202
|
+
if (res && res.ok) {
|
|
203
|
+
pool.state.failedUntil?.delete(ref);
|
|
204
|
+
pool.state.brokenUntil?.delete(ref);
|
|
205
|
+
pool.state.failCounts?.delete(ref);
|
|
206
|
+
if (Array.isArray(pool.state.events)) {
|
|
207
|
+
pool.state.events.push({ at: Date.now(), ref, reason: 'proactive-probe-heal', cooldownMs: 0, type: 'heal' });
|
|
208
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
209
|
+
}
|
|
210
|
+
logger?.warn?.(`[dsh-key-rotation] proactive-probe: restored key ${ref} in pool ${pool.base}`);
|
|
211
|
+
}
|
|
212
|
+
} catch (_) {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
logger?.warn?.('[dsh-key-rotation] proactive probe failed', e);
|
|
217
|
+
} finally {
|
|
218
|
+
running = false;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const configure = () => {
|
|
223
|
+
if (disposed) return;
|
|
224
|
+
const c = getConfig();
|
|
225
|
+
const intervalMs = (c && Number.isFinite(c.proactiveProbeIntervalMs) && c.proactiveProbeIntervalMs >= 1000)
|
|
226
|
+
? c.proactiveProbeIntervalMs : 60000;
|
|
227
|
+
if (timer !== undefined) timers.clearInterval(timer);
|
|
228
|
+
if (c?.proactiveHealthProbing === true) {
|
|
229
|
+
timer = timers.setInterval(run, intervalMs);
|
|
230
|
+
timer?.unref?.();
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const changed = () => queueMicrotask(configure);
|
|
235
|
+
const off = [
|
|
236
|
+
typeof ctx?.on === 'function' ? ctx.on('settings/document-updated', changed) : () => {},
|
|
237
|
+
typeof ctx?.on === 'function' ? ctx.on('loader/volatile-update', changed) : () => {},
|
|
238
|
+
];
|
|
239
|
+
configure();
|
|
240
|
+
return () => {
|
|
241
|
+
disposed = true;
|
|
242
|
+
if (timer !== undefined) timers.clearInterval(timer);
|
|
243
|
+
for (const stop of off) stop();
|
|
244
|
+
};
|
|
245
|
+
}, 'dsh-key-rotation: proactive health probing');
|
|
246
|
+
}
|
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/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
|
@@ -152,7 +152,7 @@ export function createRotate(deps) {
|
|
|
152
152
|
}
|
|
153
153
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
154
154
|
if (strategy !== 'round-robin') {
|
|
155
|
-
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
155
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers });
|
|
156
156
|
}
|
|
157
157
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
158
158
|
// #193 / #365: prefer least-loaded key within limit with weights
|
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.57",
|
|
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": [
|