@goodandready/dsh-key-rotation 0.8.59 → 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 CHANGED
@@ -1,5 +1,31 @@
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
+
20
+ ## 0.8.60 - 2026-10-14
21
+
22
+ ### Changed
23
+ - **PeerDependencies Expanded for DSH Core 0.2.1-alpha.2 (#484)**:
24
+ - Updated `@deepseek-ai/dsh-llm` peerDependency range to `^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0`.
25
+ - Fixes plugin skipping under DSH core `0.2.1-alpha.2` caused by `<0.2.1-0` SemVer prerelease boundary in `evaluatePluginCompatibility`.
26
+ - Verified full runtime compatibility with `dsh-llm@0.2.1-alpha.2` API, key failover rotation on 429 errors, and `/dsh-key-rotation/status` reporting.
27
+ - Added acceptance test suite `test/dsh-llm-021-alpha2.test.mjs`.
28
+
3
29
  ## 0.8.59 - 2026-10-14
4
30
 
5
31
  ### Refactored & Cleaned
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
- if (!bu || bu.size === 0) continue;
56
- const brokenRefs = [];
57
- for (const [ref, until] of bu.entries()) {
58
- if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
59
- if (Number.isFinite(until) && until > now) {
60
- brokenRefs.push(ref);
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
- for (const ref of brokenRefs) {
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 (state.brokenUntil !== bu || !bu.has(ref)) continue;
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.delete(ref);
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 || c.proactiveHealthProbing !== true) return;
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.delete(ref);
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
- pool.state.pointer = (index + 1) % list.length;
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
- // "rate limit exceeded" and "deadline exceeded" are not evidence
191
- // of an exhausted billing quota. Keep those on ordinary cooldowns.
192
- const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' ||
193
- /\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
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.59",
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": [
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0",
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0",
61
61
  "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "scripts": {