@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 +8 -0
- package/lib/index.js +6 -0
- package/lib/pool-builder.js +21 -0
- package/lib/preemptive-yield.js +129 -0
- package/lib/resolver.js +3 -0
- package/lib/rotate.js +9 -0
- package/package.json +1 -1
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
|
|
package/lib/pool-builder.js
CHANGED
|
@@ -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.
|
|
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": [
|