@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 +16 -0
- package/lib/cache-affinity.js +237 -0
- package/lib/index.js +12 -0
- package/lib/pool-builder.js +33 -0
- package/lib/preemptive-yield.js +129 -0
- package/lib/resolver.js +9 -0
- package/lib/rotate.js +18 -1
- package/lib/stream-metrics.js +6 -0
- package/package.json +1 -1
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
|
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
|
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);
|
package/lib/stream-metrics.js
CHANGED
|
@@ -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.
|
|
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": [
|