@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 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
+ }
@@ -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
+ }
@@ -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
- const weightsMap = {};
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 ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
157
- pool.state.failedUntil.set(candidate, at + waitMs);
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 ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
168
- pool.state.failedUntil.set(candidate, at + waitMs);
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.56",
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": [