@goodandready/dsh-key-rotation 0.8.60 → 0.8.61
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 +17 -0
- package/lib/client.js +3 -0
- package/lib/heal.js +22 -10
- package/lib/index.js +2 -2
- package/lib/lifecycle.js +4 -1
- package/lib/pool.js +16 -3
- package/lib/resolver.js +11 -3
- package/lib/rotate.js +11 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.61 - 2026-10-15
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Prevent Transient Rate Limit Deadlocks from Calendar Quota Locks (#486 / GitHub #24)**:
|
|
7
|
+
- Upstream errors returning transient 429, RPM/TPM exhaustion, or concurrency limits (such as SenseNova `{"message":"rpm exhausted","type":"quota_exceeded_error"}` or `{"message":"inference exceeds tpm/rpm limit","code":"insufficient_quota"}`) are now classified as `isRateThrottling` and excluded from `isQuota`.
|
|
8
|
+
- Transient rate limits receive standard short backoff (`cooldownMs` with jitter) rather than being locked out until next UTC midnight.
|
|
9
|
+
- Cleared `pool.state.quotaWindows` in `recordSuccess` upon first successful request.
|
|
10
|
+
- Extended background `autoUnbreakBrokenKeys` and proactive health probing to probe keys with calendar quota locks, restoring healthy keys immediately upon receiving 200 OK from upstream.
|
|
11
|
+
- Added `INSUFFICIENT_QUOTA` and present-tense regex matches to switchable error classification.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Sticky Key Routing Strategy to Preserve Prompt Caching (#487 / GitHub #23)**:
|
|
15
|
+
- Added `'sticky'` routing strategy option in settings and provider configurations.
|
|
16
|
+
- In `'sticky'` mode, the resolver retains the same active key across consecutive requests as long as it remains healthy, maximizing prefix prompt cache hit rate on providers that cache per API key (Anthropic, DeepSeek, OpenAI).
|
|
17
|
+
- Automatically fails over to the next healthy key in the pool if the sticky key experiences an error or cooldown, then sticks to the new key.
|
|
18
|
+
- Added UI dropdown selection in the Key Rotation Settings Card with English and Chinese localizations.
|
|
19
|
+
|
|
3
20
|
## 0.8.60 - 2026-10-14
|
|
4
21
|
|
|
5
22
|
### Changed
|
package/lib/client.js
CHANGED
|
@@ -221,6 +221,7 @@ window.__ModuleLoader__.load({
|
|
|
221
221
|
routingStrategyRoundRobin: 'Round-Robin (default)',
|
|
222
222
|
routingStrategyLeastLoaded: 'Least-Loaded (concurrency)',
|
|
223
223
|
routingStrategyLowestLatency: 'Lowest-Latency (p95)',
|
|
224
|
+
routingStrategySticky: 'Sticky (preserves prompt cache)',
|
|
224
225
|
proactiveGuardLabel: 'Proactive Rate-Limit Guard',
|
|
225
226
|
proactiveGuardHint: 'Pre-emptively pause keys when remaining quota or Retry-After header indicates limit before hitting 429',
|
|
226
227
|
selfHealingLabel: 'Auto-Unbreak Interval (min)',
|
|
@@ -405,6 +406,7 @@ window.__ModuleLoader__.load({
|
|
|
405
406
|
routingStrategyRoundRobin: '轮询 (默认)',
|
|
406
407
|
routingStrategyLeastLoaded: '最低负载 (并发)',
|
|
407
408
|
routingStrategyLowestLatency: '最低延迟 (p95)',
|
|
409
|
+
routingStrategySticky: '粘性模式 (优先保持提示词缓存)',
|
|
408
410
|
proactiveGuardLabel: '前瞻性限流防护',
|
|
409
411
|
proactiveGuardHint: '当响应中的剩余配额或 Retry-After 头提示即将超限时,预先冷却密钥以防触发 429',
|
|
410
412
|
selfHealingLabel: '自愈检查间隔 (分钟)',
|
|
@@ -2489,6 +2491,7 @@ window.__ModuleLoader__.load({
|
|
|
2489
2491
|
h('option', { key: 'round-robin', value: 'round-robin' }, t('routingStrategyRoundRobin')),
|
|
2490
2492
|
h('option', { key: 'least-loaded', value: 'least-loaded' }, t('routingStrategyLeastLoaded')),
|
|
2491
2493
|
h('option', { key: 'lowest-latency', value: 'lowest-latency' }, t('routingStrategyLowestLatency')),
|
|
2494
|
+
h('option', { key: 'sticky', value: 'sticky' }, t('routingStrategySticky')),
|
|
2492
2495
|
])),
|
|
2493
2496
|
field(t('selfHealingLabel'), textInput(String(val.selfHealingIntervalMinutes ?? 30), (v) => setField((cur) => ({ ...cur, selfHealingIntervalMinutes: Number(v) || 0 })))),
|
|
2494
2497
|
field(t('proactiveGuardLabel'), h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', height: '36px', cursor: 'pointer' } }, [
|
package/lib/heal.js
CHANGED
|
@@ -52,21 +52,31 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), {
|
|
|
52
52
|
for (const pool of pools) {
|
|
53
53
|
if (!pool || !pool.state || !pool.base) continue;
|
|
54
54
|
const bu = pool.state.brokenUntil;
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const qw = pool.state.quotaWindows;
|
|
56
|
+
const candidates = new Set();
|
|
57
|
+
if (bu && bu.size > 0) {
|
|
58
|
+
for (const [ref, until] of bu.entries()) {
|
|
59
|
+
if (!isKeyRevoked(pool, ref) && !isKeyPaused(pool, ref) && Number.isFinite(until) && until > now) {
|
|
60
|
+
candidates.add(ref);
|
|
61
|
+
}
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
if (qw && qw.size > 0) {
|
|
65
|
+
for (const [ref, until] of qw.entries()) {
|
|
66
|
+
if (!isKeyRevoked(pool, ref) && !isKeyPaused(pool, ref) && Number.isFinite(until) && until > now) {
|
|
67
|
+
candidates.add(ref);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (candidates.size === 0) continue;
|
|
72
|
+
|
|
73
|
+
for (const ref of candidates) {
|
|
64
74
|
if (!isActive()) return results;
|
|
65
75
|
const state = pool.state;
|
|
66
|
-
const fields = ['brokenUntil', 'failedUntil', 'failCounts', 'authFailCounts'];
|
|
76
|
+
const fields = ['brokenUntil', 'failedUntil', 'quotaWindows', 'failCounts', 'authFailCounts'];
|
|
67
77
|
const observed = fields.map(field => [field, state[field], state[field]?.get(ref)]);
|
|
68
78
|
// An earlier await may have removed this key from the captured worklist.
|
|
69
|
-
if (
|
|
79
|
+
if (!((bu && bu.has(ref)) || (qw && qw.has(ref)))) continue;
|
|
70
80
|
try {
|
|
71
81
|
const res = await probeFn(ref);
|
|
72
82
|
if (!isActive()) return results;
|
|
@@ -74,7 +84,9 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), {
|
|
|
74
84
|
if (pool.state !== state || observed.some(([field, map, value]) =>
|
|
75
85
|
state[field] !== map || map?.get(ref) !== value)) continue;
|
|
76
86
|
if (res && res.ok) {
|
|
77
|
-
bu
|
|
87
|
+
bu?.delete(ref);
|
|
88
|
+
qw?.delete(ref);
|
|
89
|
+
pool.state.quotaWindows?.delete(ref);
|
|
78
90
|
pool.state.failedUntil?.delete(ref);
|
|
79
91
|
pool.state.failCounts?.delete(ref);
|
|
80
92
|
pool.state.authFailCounts?.delete(ref);
|
package/lib/index.js
CHANGED
|
@@ -95,7 +95,7 @@ const SettingsSchema = Schema.object({
|
|
|
95
95
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
96
96
|
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
97
97
|
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
98
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']).default('round-robin'),
|
|
98
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']).default('round-robin'),
|
|
99
99
|
rpmLimit: Schema.number().default(0),
|
|
100
100
|
tpmLimit: Schema.number().default(0),
|
|
101
101
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
@@ -144,7 +144,7 @@ const SettingsSchema = Schema.object({
|
|
|
144
144
|
})).default({}),
|
|
145
145
|
cooldownMs: Schema.number(),
|
|
146
146
|
maxCooldownMs: Schema.number(),
|
|
147
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']),
|
|
147
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']),
|
|
148
148
|
proactiveRateLimitGuard: Schema.boolean(),
|
|
149
149
|
rpmLimit: Schema.number(),
|
|
150
150
|
tpmLimit: Schema.number(),
|
package/lib/lifecycle.js
CHANGED
|
@@ -171,8 +171,10 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
171
171
|
running = true;
|
|
172
172
|
try {
|
|
173
173
|
const c = getConfig();
|
|
174
|
-
if (!c
|
|
174
|
+
if (!c) return;
|
|
175
175
|
const { pools } = buildRuntime();
|
|
176
|
+
const hasCalendarLocks = (pools || []).some(p => p?.state?.quotaWindows?.size > 0);
|
|
177
|
+
if (c.proactiveHealthProbing !== true && !hasCalendarLocks) return;
|
|
176
178
|
const runner = ensureSandboxRunner(ctx);
|
|
177
179
|
const now = Date.now();
|
|
178
180
|
for (const pool of (pools || [])) {
|
|
@@ -203,6 +205,7 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
203
205
|
if (res && res.ok) {
|
|
204
206
|
pool.state.failedUntil?.delete(ref);
|
|
205
207
|
pool.state.brokenUntil?.delete(ref);
|
|
208
|
+
pool.state.quotaWindows?.delete(ref);
|
|
206
209
|
pool.state.failCounts?.delete(ref);
|
|
207
210
|
if (Array.isArray(pool.state.events)) {
|
|
208
211
|
pool.state.events.push({ at: Date.now(), ref, reason: 'proactive-probe-heal', cooldownMs: 0, type: 'heal' });
|
package/lib/pool.js
CHANGED
|
@@ -68,7 +68,8 @@ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
|
|
|
68
68
|
/\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
|
|
69
69
|
/\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
|
|
70
70
|
/\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
|
|
71
|
-
/\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
|
|
71
|
+
/\b(?:exceeded|exceeds|exhausted|exhausts)[\s_-]+(?:quota|limit|budget|rpm|tpm|inference)\b/i,
|
|
72
|
+
/\b(?:rpm|tpm)\b/i,
|
|
72
73
|
/\bbilling\b/i,
|
|
73
74
|
/\bresource[\s_-]+exhausted\b/i,
|
|
74
75
|
/\bcapacity[\s_-]+(?:limit|exceeded|reached)\b/i,
|
|
@@ -171,7 +172,8 @@ export function recordFailure(pool, ref, now, baseMs, maxMs, isSoft = false, jit
|
|
|
171
172
|
/** Record a success for `ref`. */
|
|
172
173
|
export function recordSuccess(pool, ref, now = Date.now()) {
|
|
173
174
|
if (pool.state.failCounts) pool.state.failCounts.delete(ref);
|
|
174
|
-
pool.state.failedUntil
|
|
175
|
+
pool.state.failedUntil?.delete(ref);
|
|
176
|
+
pool.state.quotaWindows?.delete(ref);
|
|
175
177
|
if (!pool.state.lastSuccessAt) pool.state.lastSuccessAt = new Map();
|
|
176
178
|
pool.state.lastSuccessAt.set(ref, now);
|
|
177
179
|
}
|
|
@@ -409,6 +411,17 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
409
411
|
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
410
412
|
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
411
413
|
const list = refs.slice();
|
|
414
|
+
if (strategy === 'sticky') {
|
|
415
|
+
const last = deps.lastUsed || (deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
416
|
+
if (last && list.includes(last)) {
|
|
417
|
+
const idx = list.indexOf(last);
|
|
418
|
+
if (idx > 0) {
|
|
419
|
+
list.splice(idx, 1);
|
|
420
|
+
list.unshift(last);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return list;
|
|
424
|
+
}
|
|
412
425
|
if (strategy === 'tier-priority') {
|
|
413
426
|
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
414
427
|
return list.sort((a, b) => {
|
|
@@ -482,7 +495,7 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
482
495
|
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
|
483
496
|
|
|
484
497
|
// 2. Standard gRPC / cloud error codes
|
|
485
|
-
if (code === 'RESOURCE_EXHAUSTED' && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT'))) return true;
|
|
498
|
+
if ((code === 'RESOURCE_EXHAUSTED' || code === 'INSUFFICIENT_QUOTA') && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT'))) return true;
|
|
486
499
|
if ((code === 'UNAVAILABLE' || code === 'INTERNAL') && switchCodes.has('SERVER')) return true;
|
|
487
500
|
if (code === 'DEADLINE_EXCEEDED' && switchCodes.has('TIMEOUT')) return true;
|
|
488
501
|
if ((code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED') && switchCodes.has('AUTH')) return true;
|
package/lib/resolver.js
CHANGED
|
@@ -117,8 +117,8 @@ export function createResolver({
|
|
|
117
117
|
|
|
118
118
|
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
119
119
|
let list = pool.weightedRefs ?? pool.refs;
|
|
120
|
-
if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
|
|
121
|
-
list = sortAttemptList(list, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers });
|
|
120
|
+
if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky') {
|
|
121
|
+
list = sortAttemptList(list, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers, lastUsed: pool.state.lastUsed });
|
|
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
|
|
@@ -126,6 +126,10 @@ export function createResolver({
|
|
|
126
126
|
// the loop, so it cannot be re-evaluated mid-iteration.
|
|
127
127
|
const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
|
|
128
128
|
let start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
|
|
129
|
+
if (strategy === 'sticky') {
|
|
130
|
+
const lastIdx = pool.state.lastUsed ? list.indexOf(pool.state.lastUsed) : -1;
|
|
131
|
+
start = lastIdx >= 0 ? lastIdx : (pool.state.pointer ?? 0);
|
|
132
|
+
}
|
|
129
133
|
if (pool.pinnedRef && list.includes(pool.pinnedRef)) {
|
|
130
134
|
const pIdx = list.indexOf(pool.pinnedRef);
|
|
131
135
|
if (pIdx > 0) {
|
|
@@ -217,7 +221,11 @@ export function createResolver({
|
|
|
217
221
|
|
|
218
222
|
// Advance the shared cursor at selection time: concurrent requests must
|
|
219
223
|
// not all pick the same slot.
|
|
220
|
-
|
|
224
|
+
if (strategy === 'sticky') {
|
|
225
|
+
pool.state.pointer = index;
|
|
226
|
+
} else {
|
|
227
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
228
|
+
}
|
|
221
229
|
try {
|
|
222
230
|
const hit = await original(candidate);
|
|
223
231
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
package/lib/rotate.js
CHANGED
|
@@ -155,7 +155,7 @@ export function createRotate(deps) {
|
|
|
155
155
|
}
|
|
156
156
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
157
157
|
if (strategy !== 'round-robin') {
|
|
158
|
-
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers });
|
|
158
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap, tiers: pool.tiers, lastUsed: pool.state.lastUsed });
|
|
159
159
|
}
|
|
160
160
|
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
161
161
|
// #193 / #365: prefer least-loaded key within limit with weights
|
|
@@ -186,11 +186,16 @@ export function createRotate(deps) {
|
|
|
186
186
|
const cls = classifyFailure({ code: errCode, message: errMsg });
|
|
187
187
|
const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
|
|
188
188
|
|
|
189
|
-
// #337: connect quota-window calendar reset for quota exhaustion
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
const
|
|
193
|
-
/\b(?:insufficient[_\s-]+quota|
|
|
189
|
+
// #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
|
|
190
|
+
// Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
|
|
191
|
+
// must NOT be locked until midnight. Keep those on ordinary cooldowns.
|
|
192
|
+
const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
|
|
193
|
+
(/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
|
|
194
|
+
|
|
195
|
+
const isQuota = !isRateThrottling && (
|
|
196
|
+
(errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
|
|
197
|
+
/\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
|
|
198
|
+
);
|
|
194
199
|
const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
|
|
195
200
|
if (isQuota && qWindow) {
|
|
196
201
|
const quotaResetAt = poolResetAt(pool, qWindow, now());
|
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.61",
|
|
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": [
|