@goodandready/dsh-key-rotation 0.8.85 → 0.8.87

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.87 (2026-11-03)
4
+
5
+ ### Cache-Affinity Router for Hot Prompt Cache (Block 14 Release)
6
+ - **Cache-Affinity Module (Issue #573)**: Introduced `src/cache-affinity.js` with deterministic FNV-1a prompt prefix hashing (`extractPromptPrefixKey`) and LRU-managed `CacheAffinityRouter`.
7
+ - **Hot Prompt Cache Affinity**: Binds stable system prompts and context prefixes to the provider key that has already warmed the upstream prompt cache, maintaining high hit-rates (>80%) across conversation sessions and multi-step tool calls.
8
+ - **Synergistic Proactive Routing**: Cooperates with `applyPreemptiveYield` to ensure that hot-cache keys are prioritized only when they have safe token/request headroom, preventing cache optimization from triggering 429 rate limit failures.
9
+ - **Config & Schemastery Support**: Added `cacheAffinityEnabled`, `cacheAffinityTtlMs` (default: 5 minutes), and `cacheAffinityPrefixChars` (default: 2048 chars) across Schemastery configuration, pool builders, and TypeScript types.
10
+
11
+ ## 0.8.86 (2026-11-03)
12
+
13
+ ### Pre-emptive Token & Request Yield before 429 (Block 13 Release)
14
+ - **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`).
15
+ - **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.
16
+ - **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.
17
+ - **Config & Schemastery Support**: Added `preemptiveRotationEnabled`, `preemptiveSafetyMarginTokens`, and `preemptiveSafetyMarginRequests` across Schemastery configuration, pool builders, and TypeScript types.
18
+
3
19
  ## 0.8.85 (2026-11-03)
4
20
 
5
21
  ### Inter-Token Stall & Zombie Stream Detector (Block 12 Release)
@@ -0,0 +1,237 @@
1
+ // src/cache-affinity.js — Cache-Affinity Router for hot prompt cache (#573).
2
+ // Pure module: zero runtime dependencies, strictly < 600 lines.
3
+
4
+ export const DEFAULT_CACHE_AFFINITY_TTL_MS = 300000; // 5 minutes
5
+ export const DEFAULT_CACHE_AFFINITY_PREFIX_CHARS = 2048;
6
+ export const MIN_CACHE_PREFIX_CHARS = 32;
7
+
8
+ /**
9
+ * 32-bit FNV-1a hash algorithm for fast, deterministic string hashing.
10
+ * @param {string} str
11
+ * @returns {string} Hexadecimal hash representation
12
+ */
13
+ export function fnv1a(str) {
14
+ if (typeof str !== 'string') return '0';
15
+ let hash = 0x811c9dc5;
16
+ for (let i = 0; i < str.length; i++) {
17
+ hash ^= str.charCodeAt(i);
18
+ hash = Math.imul(hash, 0x01000193);
19
+ }
20
+ return (hash >>> 0).toString(16);
21
+ }
22
+
23
+ /**
24
+ * Extracts and hashes a deterministic prompt prefix key from request options.
25
+ * Inspects system prompt, conversation messages, prompt string, or contents.
26
+ * @param {object} options - Request options (messages, prompt, system, model)
27
+ * @param {number} [maxChars=2048]
28
+ * @returns {string|null} Cache prefix key or null if insufficient content
29
+ */
30
+ export function extractPromptPrefixKey(options, maxChars = DEFAULT_CACHE_AFFINITY_PREFIX_CHARS) {
31
+ if (!options || typeof options !== 'object') return null;
32
+
33
+ let text = '';
34
+
35
+ // 1. Explicit system prompt option
36
+ if (typeof options.system === 'string' && options.system.trim()) {
37
+ text += options.system.trim() + '\n';
38
+ }
39
+
40
+ // 2. Messages array (OpenAI / Anthropic format)
41
+ if (Array.isArray(options.messages) && options.messages.length > 0) {
42
+ for (const msg of options.messages) {
43
+ if (!msg) continue;
44
+ const role = String(msg.role ?? '').toLowerCase();
45
+ let content = '';
46
+
47
+ if (typeof msg.content === 'string') {
48
+ content = msg.content;
49
+ } else if (Array.isArray(msg.content)) {
50
+ // Multipart content (e.g. [{ type: 'text', text: '...' }])
51
+ content = msg.content
52
+ .map(part => (typeof part === 'string' ? part : (part?.text ?? '')))
53
+ .filter(Boolean)
54
+ .join('\n');
55
+ }
56
+
57
+ if (content.trim()) {
58
+ text += `[${role}] ${content.trim()}\n`;
59
+ }
60
+
61
+ // Stop once we have reached or exceeded maxChars
62
+ if (text.length >= maxChars) break;
63
+ }
64
+ }
65
+
66
+ // 3. Raw prompt string
67
+ if (!text && typeof options.prompt === 'string' && options.prompt.trim()) {
68
+ text = options.prompt.trim();
69
+ }
70
+
71
+ // 4. Contents array (Gemini format)
72
+ if (!text && Array.isArray(options.contents) && options.contents.length > 0) {
73
+ for (const item of options.contents) {
74
+ const parts = item?.parts ?? [];
75
+ for (const p of parts) {
76
+ if (typeof p?.text === 'string' && p.text.trim()) {
77
+ text += p.text.trim() + '\n';
78
+ if (text.length >= maxChars) break;
79
+ }
80
+ }
81
+ if (text.length >= maxChars) break;
82
+ }
83
+ }
84
+
85
+ text = text.trim();
86
+ if (text.length < MIN_CACHE_PREFIX_CHARS) {
87
+ return null;
88
+ }
89
+
90
+ const slice = text.slice(0, maxChars);
91
+ const hash = fnv1a(slice);
92
+ const model = options.model ? String(options.model).trim() : '';
93
+
94
+ return model ? `${model}:${hash}` : hash;
95
+ }
96
+
97
+ /**
98
+ * CacheAffinityRouter tracks which key has warmed the upstream prompt cache.
99
+ */
100
+ export class CacheAffinityRouter {
101
+ constructor(options = {}) {
102
+ this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_AFFINITY_TTL_MS;
103
+ this.maxEntries = options.maxEntries ?? 1000;
104
+ this.entries = new Map();
105
+ }
106
+
107
+ resolveAffinity(cacheKey, candidateRefs, now = Date.now()) {
108
+ if (!cacheKey || !this.entries.has(cacheKey) || !Array.isArray(candidateRefs) || candidateRefs.length === 0) {
109
+ return null;
110
+ }
111
+
112
+ const entry = this.entries.get(cacheKey);
113
+ if (!entry) return null;
114
+
115
+ if (now >= entry.expiresAt) {
116
+ this.entries.delete(cacheKey);
117
+ return null;
118
+ }
119
+
120
+ if (!candidateRefs.includes(entry.ref)) {
121
+ return null;
122
+ }
123
+
124
+ entry.hits = (entry.hits || 0) + 1;
125
+ return {
126
+ ref: entry.ref,
127
+ hits: entry.hits,
128
+ warmedAt: entry.warmedAt,
129
+ expiresAt: entry.expiresAt,
130
+ ttlRemainingMs: Math.max(0, entry.expiresAt - now),
131
+ };
132
+ }
133
+
134
+ recordAffinity(cacheKey, ref, options = {}) {
135
+ if (!cacheKey || !ref) return;
136
+ const now = options.now ?? Date.now();
137
+ const ttlMs = options.ttlMs ?? this.ttlMs;
138
+
139
+ if (this.entries.size >= this.maxEntries && !this.entries.has(cacheKey)) {
140
+ const oldestKey = this.entries.keys().next().value;
141
+ if (oldestKey) this.entries.delete(oldestKey);
142
+ }
143
+
144
+ const existing = this.entries.get(cacheKey);
145
+ const hits = existing ? (existing.hits || 0) : 0;
146
+
147
+ this.entries.set(cacheKey, {
148
+ ref,
149
+ warmedAt: existing?.warmedAt ?? now,
150
+ lastSeenAt: now,
151
+ expiresAt: now + ttlMs,
152
+ hits,
153
+ model: options.model,
154
+ });
155
+ }
156
+
157
+ deleteAffinity(cacheKey) {
158
+ if (cacheKey) this.entries.delete(cacheKey);
159
+ }
160
+
161
+ clear() {
162
+ this.entries.clear();
163
+ }
164
+
165
+ get size() {
166
+ return this.entries.size;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Obtains or lazily creates a CacheAffinityRouter on a pool state.
172
+ * @param {object} pool
173
+ * @returns {CacheAffinityRouter}
174
+ */
175
+ export function getOrCreateCacheAffinityRouter(pool) {
176
+ if (!pool) return new CacheAffinityRouter();
177
+ if (!pool.state) pool.state = {};
178
+ if (!pool.state.cacheAffinityRouter || !(pool.state.cacheAffinityRouter instanceof CacheAffinityRouter)) {
179
+ pool.state.cacheAffinityRouter = new CacheAffinityRouter({
180
+ ttlMs: pool.cacheAffinityTtlMs ?? DEFAULT_CACHE_AFFINITY_TTL_MS,
181
+ });
182
+ }
183
+ return pool.state.cacheAffinityRouter;
184
+ }
185
+
186
+ /**
187
+ * Applies prompt cache affinity ordering to candidate attempt list.
188
+ * @param {string[]} attemptList
189
+ * @param {object} pool
190
+ * @param {object} [options]
191
+ * @returns {string[]}
192
+ */
193
+ export function applyCacheAffinity(attemptList, pool, options = {}) {
194
+ if (!Array.isArray(attemptList) || attemptList.length <= 1 || !pool) {
195
+ return (attemptList ?? []).slice();
196
+ }
197
+
198
+ const enabled = options.enabled ?? pool.cacheAffinityEnabled ?? true;
199
+ if (!enabled) return attemptList.slice();
200
+
201
+ const cacheKey = options.cacheKey;
202
+ if (!cacheKey) return attemptList.slice();
203
+
204
+ // If a pinned key is configured and already first, keep pinned priority
205
+ if (pool.pinnedRef && attemptList[0] === pool.pinnedRef && options.preservePinned !== false) {
206
+ return attemptList.slice();
207
+ }
208
+
209
+ const router = getOrCreateCacheAffinityRouter(pool);
210
+ const now = options.now ?? Date.now();
211
+ const affinity = router.resolveAffinity(cacheKey, attemptList, now);
212
+
213
+ if (!affinity || !affinity.ref) {
214
+ return attemptList.slice();
215
+ }
216
+
217
+ const targetRef = affinity.ref;
218
+ const list = attemptList.slice();
219
+ const idx = list.indexOf(targetRef);
220
+
221
+ if (idx > 0) {
222
+ list.splice(idx, 1);
223
+ list.unshift(targetRef);
224
+
225
+ if (options.trace && typeof options.trace.addStep === 'function') {
226
+ options.trace.addStep(targetRef, 'PRIORITIZED', 'CACHE_AFFINITY', `Prompt cache hit affinity (key ${targetRef}, hits: ${affinity.hits})`);
227
+ }
228
+ if (options.pushEvent && typeof options.pushEvent === 'function') {
229
+ options.pushEvent(pool, targetRef, 'CACHE_AFFINITY_HIT', { cacheKey, hits: affinity.hits });
230
+ }
231
+ if (pool.state) {
232
+ pool.state.cacheAffinityHits = (pool.state.cacheAffinityHits ?? 0) + 1;
233
+ }
234
+ }
235
+
236
+ return list;
237
+ }
package/lib/index.js CHANGED
@@ -140,6 +140,12 @@ 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),
146
+ cacheAffinityEnabled: Schema.boolean().default(true),
147
+ cacheAffinityTtlMs: Schema.number().default(300000),
148
+ cacheAffinityPrefixChars: Schema.number().default(2048),
143
149
  providers: Schema.array(Schema.object({
144
150
  provider: Schema.string().required(),
145
151
  keys: Schema.array(Schema.string()).default([]),
@@ -172,6 +178,12 @@ const SettingsSchema = Schema.object({
172
178
  stallDetectionEnabled: Schema.boolean(),
173
179
  stallTimeoutMs: Schema.number(),
174
180
  stallInitialTimeoutMs: Schema.number(),
181
+ preemptiveRotationEnabled: Schema.boolean(),
182
+ preemptiveSafetyMarginTokens: Schema.number(),
183
+ preemptiveSafetyMarginRequests: Schema.number(),
184
+ cacheAffinityEnabled: Schema.boolean(),
185
+ cacheAffinityTtlMs: Schema.number(),
186
+ cacheAffinityPrefixChars: Schema.number(),
175
187
  })).default([...DEFAULT_PROVIDERS]),
176
188
  });
177
189
 
@@ -44,6 +44,12 @@ export function buildPoolItem({
44
44
  stallDetectionEnabled = true,
45
45
  stallTimeoutMs = 15000,
46
46
  stallInitialTimeoutMs = 30000,
47
+ preemptiveRotationEnabled = true,
48
+ preemptiveSafetyMarginTokens = 4000,
49
+ preemptiveSafetyMarginRequests = 2,
50
+ cacheAffinityEnabled = true,
51
+ cacheAffinityTtlMs = 300000,
52
+ cacheAffinityPrefixChars = 2048,
47
53
  }) {
48
54
  const seen = new Set();
49
55
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -157,6 +163,15 @@ const weightsMap = {};
157
163
  stallDetectionEnabled: stallDetectionEnabled ?? true,
158
164
  stallTimeoutMs: typeof stallTimeoutMs === 'number' && Number.isFinite(stallTimeoutMs) && stallTimeoutMs > 0 ? Math.floor(stallTimeoutMs) : 15000,
159
165
  stallInitialTimeoutMs: typeof stallInitialTimeoutMs === 'number' && Number.isFinite(stallInitialTimeoutMs) && stallInitialTimeoutMs > 0 ? Math.floor(stallInitialTimeoutMs) : 30000,
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,
169
+ cacheAffinityEnabled: cacheAffinityEnabled ?? true,
170
+ cacheAffinityTtlMs: typeof cacheAffinityTtlMs === 'number' && Number.isFinite(cacheAffinityTtlMs) && cacheAffinityTtlMs > 0 ? Math.floor(cacheAffinityTtlMs) : 300000,
171
+ cacheAffinityPrefixChars: typeof cacheAffinityPrefixChars === 'number' && Number.isFinite(cacheAffinityPrefixChars) && cacheAffinityPrefixChars > 0 ? Math.floor(cacheAffinityPrefixChars) : 2048,
172
+ preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
173
+ preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
174
+ preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
160
175
  };
161
176
  }
162
177
 
@@ -207,6 +222,15 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
207
222
  stallDetectionEnabled: p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
208
223
  stallTimeoutMs: p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
209
224
  stallInitialTimeoutMs: p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
225
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
226
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
227
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
228
+ cacheAffinityEnabled: p.cacheAffinityEnabled ?? cfg.cacheAffinityEnabled ?? true,
229
+ cacheAffinityTtlMs: p.cacheAffinityTtlMs ?? cfg.cacheAffinityTtlMs ?? 300000,
230
+ cacheAffinityPrefixChars: p.cacheAffinityPrefixChars ?? cfg.cacheAffinityPrefixChars ?? 2048,
231
+ preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
232
+ preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
233
+ preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
210
234
  }, provRpm, provTpm, provConcurrency);
211
235
  if (pool) {
212
236
  for (const ref of pool.refs) {
@@ -242,6 +266,15 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
242
266
  stallDetectionEnabled: mp.stallDetectionEnabled ?? p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
243
267
  stallTimeoutMs: mp.stallTimeoutMs ?? p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
244
268
  stallInitialTimeoutMs: mp.stallInitialTimeoutMs ?? p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
269
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
270
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
271
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
272
+ cacheAffinityEnabled: mp.cacheAffinityEnabled ?? p.cacheAffinityEnabled ?? cfg.cacheAffinityEnabled ?? true,
273
+ cacheAffinityTtlMs: mp.cacheAffinityTtlMs ?? p.cacheAffinityTtlMs ?? cfg.cacheAffinityTtlMs ?? 300000,
274
+ cacheAffinityPrefixChars: mp.cacheAffinityPrefixChars ?? p.cacheAffinityPrefixChars ?? cfg.cacheAffinityPrefixChars ?? 2048,
275
+ preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
276
+ preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
277
+ preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
245
278
  }, modelRpm, modelTpm, modelConcurrency);
246
279
  if (!mpool) continue;
247
280
  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,8 @@
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';
15
+ import { extractPromptPrefixKey, applyCacheAffinity } from './cache-affinity.js';
14
16
  import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
15
17
  import {
16
18
  isModelQuotaAvailable,
@@ -132,6 +134,13 @@ export function createResolver({
132
134
  lastUsed: pool.state.lastUsed,
133
135
  });
134
136
  }
137
+ // Issue #573: Cache-Affinity Router
138
+ const cacheKey = extractPromptPrefixKey(resolveOpts, pool.cacheAffinityPrefixChars);
139
+ if (cacheKey) {
140
+ list = applyCacheAffinity(list, pool, { cacheKey, now: Date.now() });
141
+ }
142
+ // Issue #571: Pre-emptive Token & Request Yield before 429
143
+ list = applyPreemptiveYield(list, pool, { now: Date.now() });
135
144
  // Fail closed: when this request is managed by a model pool that has local
136
145
  // token budgets, an exhausted candidate may not be dispatched and the
137
146
  // original credential must never be used as a bypass. Decided once, before
package/lib/rotate.js CHANGED
@@ -27,6 +27,8 @@ 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';
31
+ import { extractPromptPrefixKey, applyCacheAffinity } from './cache-affinity.js';
30
32
  import { penalizeKey } from './rotate-penalty.js';
31
33
  import { recordPromptCache } from './prompt-cache.js';
32
34
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
@@ -108,7 +110,8 @@ export function createRotate(deps) {
108
110
  const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
109
111
  let activePermit = null;
110
112
  const sessionId = options?.sessionId ?? options?.session ?? options?.metadata?.sessionId ?? options?.chatId ?? options?.conversationId;
111
- const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now(), sessionId, model: options.model };
113
+ const cacheKey = extractPromptPrefixKey(options, pool.cacheAffinityPrefixChars);
114
+ const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now(), sessionId, model: options.model, cacheKey };
112
115
  setRotateStartMs(reqStore.startMs);
113
116
  const wallNow = Date.now();
114
117
  const quotaManaged = hasModelQuotaConfig(pool);
@@ -203,6 +206,19 @@ export function createRotate(deps) {
203
206
  }
204
207
  }
205
208
 
209
+ // Issue #573: Cache-Affinity Router
210
+ if (reqStore.cacheKey) {
211
+ attemptList = applyCacheAffinity(attemptList, pool, { cacheKey: reqStore.cacheKey, now: wallNow, trace, pushEvent });
212
+ }
213
+
214
+ // Issue #571: Pre-emptive Token & Request Yield before 429
215
+ attemptList = applyPreemptiveYield(attemptList, pool, {
216
+ now: wallNow,
217
+ trace,
218
+ pushEvent,
219
+ logWarn,
220
+ });
221
+
206
222
  const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
207
223
  penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
208
224
  pool,
@@ -439,6 +455,7 @@ export function createRotate(deps) {
439
455
  rawHdrs,
440
456
  pushEvent,
441
457
  quotaStore,
458
+ cacheKey: reqStore?.cacheKey,
442
459
  });
443
460
  }
444
461
  recordLatency(pool, reqStore);
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { recordSuccess, setSessionSticky, recordFailure, extractRateLimit, isRateLimited } from './pool.js';
5
5
  import { recordPromptCache } from './prompt-cache.js';
6
+ import { getOrCreateCacheAffinityRouter } from './cache-affinity.js';
6
7
  import { recordRequestOutcome } from './ops-analytics.js';
7
8
  import { tpmRecord } from './bucket.js';
8
9
  import { extractUsageTokens, consumeModelTokens } from './model-quota.js';
@@ -49,6 +50,7 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
49
50
  rawHdrs,
50
51
  pushEvent,
51
52
  quotaStore,
53
+ cacheKey,
52
54
  } = deps;
53
55
 
54
56
  const nowMs = typeof now === 'function' ? now() : Date.now();
@@ -61,6 +63,10 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
61
63
  // 2. Prompt cache and analytics outcome
62
64
  const usagePayload = chunk?.usage ?? chunk?.metadata?.usage;
63
65
  recordPromptCache(pool, activeRef, options.model, usagePayload, wallNow);
66
+ if (cacheKey) {
67
+ const affinityRouter = getOrCreateCacheAffinityRouter(pool);
68
+ affinityRouter.recordAffinity(cacheKey, activeRef, { now: wallNow, model: options.model });
69
+ }
64
70
  recordRequestOutcome(pool, {
65
71
  ref: activeRef,
66
72
  success: true,
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.87",
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": [