@goodandready/dsh-key-rotation 0.8.85 → 0.8.86

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.86 (2026-11-03)
4
+
5
+ ### Pre-emptive Token & Request Yield before 429 (Block 13 Release)
6
+ - **Pre-emptive Yield Module (Issue #571)**: Introduced `src/preemptive-yield.js` to evaluate active token and request headroom across provider keys using upstream `x-ratelimit-remaining-tokens`, `x-ratelimit-remaining-requests`, and reset window headers (`x-ratelimit-reset-tokens`).
7
+ - **Zero-Round-Trip Proactive Rotation**: Keys with low token balance (`< preemptiveSafetyMarginTokens`, default: 4000) or low request count (`<= preemptiveSafetyMarginRequests`, default: 2) yield execution priority to candidate keys with ample quota before any HTTP `429 Too Many Requests` or `insufficient_quota` failure occurs.
8
+ - **Graceful Degradation & Reset Recovery**: When upstream reset windows expire, capacity automatically restores to full priority. If all candidate keys in a pool are running on low quota, requests proceed using the key with highest remaining tokens or soonest reset time, avoiding false exhaustion.
9
+ - **Config & Schemastery Support**: Added `preemptiveRotationEnabled`, `preemptiveSafetyMarginTokens`, and `preemptiveSafetyMarginRequests` across Schemastery configuration, pool builders, and TypeScript types.
10
+
3
11
  ## 0.8.85 (2026-11-03)
4
12
 
5
13
  ### Inter-Token Stall & Zombie Stream Detector (Block 12 Release)
package/lib/index.js CHANGED
@@ -140,6 +140,9 @@ const SettingsSchema = Schema.object({
140
140
  stallDetectionEnabled: Schema.boolean().default(true),
141
141
  stallTimeoutMs: Schema.number().default(15000),
142
142
  stallInitialTimeoutMs: Schema.number().default(30000),
143
+ preemptiveRotationEnabled: Schema.boolean().default(true),
144
+ preemptiveSafetyMarginTokens: Schema.number().default(4000),
145
+ preemptiveSafetyMarginRequests: Schema.number().default(2),
143
146
  providers: Schema.array(Schema.object({
144
147
  provider: Schema.string().required(),
145
148
  keys: Schema.array(Schema.string()).default([]),
@@ -172,6 +175,9 @@ const SettingsSchema = Schema.object({
172
175
  stallDetectionEnabled: Schema.boolean(),
173
176
  stallTimeoutMs: Schema.number(),
174
177
  stallInitialTimeoutMs: Schema.number(),
178
+ preemptiveRotationEnabled: Schema.boolean(),
179
+ preemptiveSafetyMarginTokens: Schema.number(),
180
+ preemptiveSafetyMarginRequests: Schema.number(),
175
181
  })).default([...DEFAULT_PROVIDERS]),
176
182
  });
177
183
 
@@ -44,6 +44,9 @@ export function buildPoolItem({
44
44
  stallDetectionEnabled = true,
45
45
  stallTimeoutMs = 15000,
46
46
  stallInitialTimeoutMs = 30000,
47
+ preemptiveRotationEnabled = true,
48
+ preemptiveSafetyMarginTokens = 4000,
49
+ preemptiveSafetyMarginRequests = 2,
47
50
  }) {
48
51
  const seen = new Set();
49
52
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -157,6 +160,12 @@ const weightsMap = {};
157
160
  stallDetectionEnabled: stallDetectionEnabled ?? true,
158
161
  stallTimeoutMs: typeof stallTimeoutMs === 'number' && Number.isFinite(stallTimeoutMs) && stallTimeoutMs > 0 ? Math.floor(stallTimeoutMs) : 15000,
159
162
  stallInitialTimeoutMs: typeof stallInitialTimeoutMs === 'number' && Number.isFinite(stallInitialTimeoutMs) && stallInitialTimeoutMs > 0 ? Math.floor(stallInitialTimeoutMs) : 30000,
163
+ preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
164
+ preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
165
+ preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
166
+ preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
167
+ preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
168
+ preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
160
169
  };
161
170
  }
162
171
 
@@ -207,6 +216,12 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
207
216
  stallDetectionEnabled: p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
208
217
  stallTimeoutMs: p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
209
218
  stallInitialTimeoutMs: p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
219
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
220
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
221
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
222
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
223
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
224
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
210
225
  }, provRpm, provTpm, provConcurrency);
211
226
  if (pool) {
212
227
  for (const ref of pool.refs) {
@@ -242,6 +257,12 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
242
257
  stallDetectionEnabled: mp.stallDetectionEnabled ?? p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
243
258
  stallTimeoutMs: mp.stallTimeoutMs ?? p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
244
259
  stallInitialTimeoutMs: mp.stallInitialTimeoutMs ?? p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
260
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
261
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
262
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
263
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
264
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
265
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
245
266
  }, modelRpm, modelTpm, modelConcurrency);
246
267
  if (!mpool) continue;
247
268
  mpool.basePool = pool;
@@ -0,0 +1,129 @@
1
+ // src/preemptive-yield.js — Pre-emptive Token & Request Yield before 429 (#571).
2
+ // Pure module: zero runtime dependencies, strictly < 600 lines.
3
+
4
+ export const DEFAULT_PREEMPTIVE_SAFETY_MARGIN_TOKENS = 4000;
5
+ export const DEFAULT_PREEMPTIVE_SAFETY_MARGIN_REQUESTS = 2;
6
+
7
+ /**
8
+ * Evaluates whether a key has low remaining quota and should yield to other candidates.
9
+ * @param {object} pool - Pool definition carrying state
10
+ * @param {string} ref - Key reference
11
+ * @param {object} [options]
12
+ * @param {number} [options.now] - Current timestamp in ms
13
+ * @param {number} [options.safetyMarginTokens]
14
+ * @param {number} [options.safetyMarginRequests]
15
+ * @param {boolean} [options.enabled]
16
+ * @returns {{ yielding: boolean, reason?: string, remainingTokens?: number, remainingRequests?: number, resetInMs?: number }}
17
+ */
18
+ export function isPreemptiveYielding(pool, ref, options = {}) {
19
+ const enabled = options.enabled ?? pool?.preemptiveRotationEnabled ?? true;
20
+ if (!enabled || !pool || !ref) return { yielding: false };
21
+
22
+ const limits = pool.state?.detectedLimits?.get?.(ref);
23
+ if (!limits) return { yielding: false };
24
+
25
+ const now = options.now ?? Date.now();
26
+ const recordedAt = limits.at || now;
27
+
28
+ // Window reset calculation
29
+ const resetMs = limits.tpmResetMs || limits.rpmResetMs || (typeof limits.resetTokens === 'number' ? limits.resetTokens * 1000 : (typeof limits.resetRequests === 'number' ? limits.resetRequests * 1000 : 0));
30
+
31
+ if (resetMs > 0 && now >= recordedAt + resetMs) {
32
+ // Window has already passed and refreshed upstream
33
+ return { yielding: false };
34
+ }
35
+
36
+ const remainingTokens = limits.remainingTokens ?? limits.tpmRemaining;
37
+ const remainingRequests = limits.remainingRequests ?? limits.rpmRemaining;
38
+
39
+ const marginTokens = options.safetyMarginTokens ?? pool.preemptiveSafetyMarginTokens ?? DEFAULT_PREEMPTIVE_SAFETY_MARGIN_TOKENS;
40
+ const marginRequests = options.safetyMarginRequests ?? pool.preemptiveSafetyMarginRequests ?? DEFAULT_PREEMPTIVE_SAFETY_MARGIN_REQUESTS;
41
+
42
+ const lowTokens = typeof remainingTokens === 'number' && remainingTokens < marginTokens;
43
+ const lowRequests = typeof remainingRequests === 'number' && remainingRequests <= marginRequests;
44
+
45
+ if (lowTokens || lowRequests) {
46
+ const resetInMs = resetMs > 0 ? Math.max(0, (recordedAt + resetMs) - now) : 0;
47
+ return {
48
+ yielding: true,
49
+ reason: lowTokens ? `LOW_REMAINING_TOKENS (${remainingTokens} < ${marginTokens})` : `LOW_REMAINING_REQUESTS (${remainingRequests} <= ${marginRequests})`,
50
+ remainingTokens,
51
+ remainingRequests,
52
+ resetInMs,
53
+ };
54
+ }
55
+
56
+ return { yielding: false, remainingTokens, remainingRequests };
57
+ }
58
+
59
+ /**
60
+ * Reorders candidate attempts list so that keys with ample token/request headroom
61
+ * are attempted first, while yielding keys yield priority.
62
+ * Gracefully degrades if all candidate keys are low on headroom.
63
+ * @param {string[]} attemptList
64
+ * @param {object} pool
65
+ * @param {object} [options]
66
+ * @returns {string[]}
67
+ */
68
+ export function applyPreemptiveYield(attemptList, pool, options = {}) {
69
+ if (!Array.isArray(attemptList) || attemptList.length <= 1 || !pool) {
70
+ return (attemptList ?? []).slice();
71
+ }
72
+
73
+ const enabled = options.enabled ?? pool.preemptiveRotationEnabled ?? true;
74
+ if (!enabled) return attemptList.slice();
75
+
76
+ const now = options.now ?? Date.now();
77
+ const ample = [];
78
+ const yielding = [];
79
+
80
+ for (const ref of attemptList) {
81
+ // Pinned keys stay first if explicitly configured and not overridden
82
+ if (ref === pool.pinnedRef && options.preservePinned !== false) {
83
+ ample.push({ ref, info: { yielding: false } });
84
+ continue;
85
+ }
86
+
87
+ const info = isPreemptiveYielding(pool, ref, { ...options, now });
88
+ if (info.yielding) {
89
+ yielding.push({ ref, info });
90
+ if (options.trace && typeof options.trace.addStep === 'function') {
91
+ options.trace.addStep(ref, 'DEPRIORITIZED', 'PREEMPTIVE_YIELD', info.reason);
92
+ }
93
+ if (options.pushEvent && typeof options.pushEvent === 'function') {
94
+ options.pushEvent(pool, ref, 'PREEMPTIVE_YIELD', info);
95
+ }
96
+ } else {
97
+ ample.push({ ref, info });
98
+ }
99
+ }
100
+
101
+ if (yielding.length === 0) {
102
+ return attemptList.slice();
103
+ }
104
+
105
+ // Record yield counter in pool state
106
+ if (pool.state) {
107
+ pool.state.preemptiveYields = (pool.state.preemptiveYields ?? 0) + yielding.length;
108
+ }
109
+
110
+ // Sort yielding keys among themselves: highest remaining tokens first, then soonest reset
111
+ yielding.sort((a, b) => {
112
+ const ta = a.info.remainingTokens ?? -1;
113
+ const tb = b.info.remainingTokens ?? -1;
114
+ if (ta !== tb) return tb - ta; // Descending tokens
115
+ const ra = a.info.resetInMs ?? Infinity;
116
+ const rb = b.info.resetInMs ?? Infinity;
117
+ return ra - rb; // Ascending reset duration (soonest reset first)
118
+ });
119
+
120
+ if (ample.length === 0) {
121
+ // Graceful degradation: all keys are yielding; pick the best yielding key first
122
+ if (options.logWarn && typeof options.logWarn === 'function') {
123
+ options.logWarn(`[dsh-key-rotation] ${pool.provider ?? '-'}: all candidate keys are yielding (low quota) — proceeding with highest remaining headroom`);
124
+ }
125
+ return yielding.map(y => y.ref);
126
+ }
127
+
128
+ return ample.map(a => a.ref).concat(yielding.map(y => y.ref));
129
+ }
package/lib/resolver.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
12
12
  import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict, getSessionSticky, setSessionSticky } from './pool.js';
13
13
  import { decryptSecret } from './crypto-storage.js';
14
+ import { applyPreemptiveYield } from './preemptive-yield.js';
14
15
  import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
15
16
  import {
16
17
  isModelQuotaAvailable,
@@ -132,6 +133,8 @@ export function createResolver({
132
133
  lastUsed: pool.state.lastUsed,
133
134
  });
134
135
  }
136
+ // Issue #571: Pre-emptive Token & Request Yield before 429
137
+ list = applyPreemptiveYield(list, pool, { now: Date.now() });
135
138
  // Fail closed: when this request is managed by a model pool that has local
136
139
  // token budgets, an exhausted candidate may not be dispatched and the
137
140
  // original credential must never be used as a bypass. Decided once, before
package/lib/rotate.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { LOCAL_POOL_EXHAUSTED_CODE, resolveKeyDirectly } from './resolver.js';
28
28
  import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
29
29
  import { withStreamWatchdog, StreamStallError } from './stream-watchdog.js';
30
+ import { applyPreemptiveYield } from './preemptive-yield.js';
30
31
  import { penalizeKey } from './rotate-penalty.js';
31
32
  import { recordPromptCache } from './prompt-cache.js';
32
33
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
@@ -203,6 +204,14 @@ export function createRotate(deps) {
203
204
  }
204
205
  }
205
206
 
207
+ // Issue #571: Pre-emptive Token & Request Yield before 429
208
+ attemptList = applyPreemptiveYield(attemptList, pool, {
209
+ now: wallNow,
210
+ trace,
211
+ pushEvent,
212
+ logWarn,
213
+ });
214
+
206
215
  const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
207
216
  penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
208
217
  pool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.85",
3
+ "version": "0.8.86",
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": [