@goodandready/dsh-key-rotation 0.8.75 → 0.8.77
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 +14 -0
- package/lib/client.js +13 -1
- package/lib/error-taxonomy.js +59 -10
- package/lib/errors.js +302 -0
- package/lib/index.js +3 -0
- package/lib/ops-config-io.js +32 -5
- package/lib/pool-selection.js +92 -17
- package/lib/pool.js +39 -5
- package/lib/rotate.js +61 -11
- package/lib/safe-fetch.js +161 -28
- package/lib/session-router.js +265 -0
- package/package.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// lib/session-router.js — Bounded SessionRouter with LRU eviction and proactive unsticking (#548).
|
|
2
|
+
// Prevents unbounded memory growth and unpins sessions from failed/cooldown keys.
|
|
3
|
+
|
|
4
|
+
export const MAX_SESSIONS = 1000;
|
|
5
|
+
export const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
6
|
+
|
|
7
|
+
export class SessionRouter {
|
|
8
|
+
/**
|
|
9
|
+
* @param {{
|
|
10
|
+
* maxSessions?: number,
|
|
11
|
+
* sessionTtlMs?: number,
|
|
12
|
+
* now?: () => number,
|
|
13
|
+
* }} [options]
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.maxSessions = options.maxSessions ?? MAX_SESSIONS;
|
|
17
|
+
this.sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
|
|
18
|
+
this.now = options.now ?? (() => Date.now());
|
|
19
|
+
// sessionId -> { keyRef: string, assignedAt: number, lastUsedAt: number }
|
|
20
|
+
this.sessions = new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Evict sessions older than TTL and prune LRU entries if capacity exceeded.
|
|
25
|
+
*
|
|
26
|
+
* @param {number} [currentTime]
|
|
27
|
+
* @returns {number} count of evicted sessions
|
|
28
|
+
*/
|
|
29
|
+
prune(currentTime = this.now()) {
|
|
30
|
+
let evicted = 0;
|
|
31
|
+
// 1. Evict by TTL
|
|
32
|
+
for (const [id, entry] of this.sessions.entries()) {
|
|
33
|
+
if (currentTime - entry.lastUsedAt > this.sessionTtlMs) {
|
|
34
|
+
this.sessions.delete(id);
|
|
35
|
+
evicted++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Evict least-recently used if size exceeds maxSessions
|
|
40
|
+
while (this.sessions.size > this.maxSessions) {
|
|
41
|
+
let oldestId = null;
|
|
42
|
+
let oldestTime = Infinity;
|
|
43
|
+
for (const [id, entry] of this.sessions.entries()) {
|
|
44
|
+
if (entry.lastUsedAt < oldestTime) {
|
|
45
|
+
oldestTime = entry.lastUsedAt;
|
|
46
|
+
oldestId = id;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (oldestId !== null) {
|
|
50
|
+
this.sessions.delete(oldestId);
|
|
51
|
+
evicted++;
|
|
52
|
+
} else {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return evicted;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Count how many active sessions are currently assigned to each key.
|
|
62
|
+
*
|
|
63
|
+
* @param {number} [currentTime]
|
|
64
|
+
* @returns {Map<string, number>}
|
|
65
|
+
*/
|
|
66
|
+
getKeyAssignmentCounts(currentTime = this.now()) {
|
|
67
|
+
const counts = new Map();
|
|
68
|
+
for (const entry of this.sessions.values()) {
|
|
69
|
+
if (currentTime - entry.lastUsedAt <= this.sessionTtlMs) {
|
|
70
|
+
counts.set(entry.keyRef, (counts.get(entry.keyRef) ?? 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return counts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Select candidate key with the least active sessions assigned (Least-Used Initial Assignment).
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]} availableRefs List of candidate key refs
|
|
80
|
+
* @param {number} [currentTime]
|
|
81
|
+
* @returns {string|null}
|
|
82
|
+
*/
|
|
83
|
+
selectLeastUsedKey(availableRefs, currentTime = this.now()) {
|
|
84
|
+
if (!Array.isArray(availableRefs) || availableRefs.length === 0) return null;
|
|
85
|
+
if (availableRefs.length === 1) return availableRefs[0];
|
|
86
|
+
|
|
87
|
+
const counts = this.getKeyAssignmentCounts(currentTime);
|
|
88
|
+
let minKey = availableRefs[0];
|
|
89
|
+
let minCount = counts.get(minKey) ?? 0;
|
|
90
|
+
|
|
91
|
+
for (let i = 1; i < availableRefs.length; i++) {
|
|
92
|
+
const key = availableRefs[i];
|
|
93
|
+
const count = counts.get(key) ?? 0;
|
|
94
|
+
if (count < minCount) {
|
|
95
|
+
minCount = count;
|
|
96
|
+
minKey = key;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return minKey;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve pinned key for a session or allocate via Least-Used Initial Assignment.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} sessionId
|
|
107
|
+
* @param {string[]} availableRefs Candidate keys currently healthy/available
|
|
108
|
+
* @param {{ preferKey?: string }} [options]
|
|
109
|
+
* @returns {{ keyRef: string, isReassigned: boolean, reason: string } | null}
|
|
110
|
+
*/
|
|
111
|
+
resolveSessionKey(sessionId, availableRefs, options = {}) {
|
|
112
|
+
if (!sessionId || !Array.isArray(availableRefs) || availableRefs.length === 0) {
|
|
113
|
+
const fallback = availableRefs?.[0] ?? null;
|
|
114
|
+
return fallback ? { keyRef: fallback, isReassigned: false, reason: 'ad_hoc' } : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const t = this.now();
|
|
118
|
+
let existing = this.sessions.get(sessionId);
|
|
119
|
+
|
|
120
|
+
// Check TTL
|
|
121
|
+
if (existing && (t - existing.lastUsedAt > this.sessionTtlMs)) {
|
|
122
|
+
this.sessions.delete(sessionId);
|
|
123
|
+
existing = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If session is already pinned to an available key:
|
|
127
|
+
if (existing && availableRefs.includes(existing.keyRef)) {
|
|
128
|
+
existing.lastUsedAt = t;
|
|
129
|
+
return { keyRef: existing.keyRef, isReassigned: false, reason: 'sticky_pinned' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Prune before adding new entry
|
|
133
|
+
if (!existing && this.sessions.size >= this.maxSessions) {
|
|
134
|
+
this.prune(t);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Select candidate: prefer explicit key or least-used
|
|
138
|
+
let chosen = options.preferKey && availableRefs.includes(options.preferKey)
|
|
139
|
+
? options.preferKey
|
|
140
|
+
: this.selectLeastUsedKey(availableRefs, t);
|
|
141
|
+
|
|
142
|
+
if (!chosen) chosen = availableRefs[0];
|
|
143
|
+
|
|
144
|
+
const wasReassigned = Boolean(existing);
|
|
145
|
+
this.sessions.set(sessionId, {
|
|
146
|
+
keyRef: chosen,
|
|
147
|
+
assignedAt: existing ? existing.assignedAt : t,
|
|
148
|
+
lastUsedAt: t,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
keyRef: chosen,
|
|
153
|
+
isReassigned: wasReassigned,
|
|
154
|
+
reason: wasReassigned ? 'mid_session_failover' : 'new_session_assigned',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pin or update session sticky assignment explicitly.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} sessionId
|
|
162
|
+
* @param {string} keyRef
|
|
163
|
+
*/
|
|
164
|
+
setSession(sessionId, keyRef) {
|
|
165
|
+
if (!sessionId || !keyRef) return;
|
|
166
|
+
const t = this.now();
|
|
167
|
+
const existing = this.sessions.get(sessionId);
|
|
168
|
+
this.sessions.set(sessionId, {
|
|
169
|
+
keyRef,
|
|
170
|
+
assignedAt: existing ? existing.assignedAt : t,
|
|
171
|
+
lastUsedAt: t,
|
|
172
|
+
});
|
|
173
|
+
if (this.sessions.size > this.maxSessions) {
|
|
174
|
+
this.prune(t);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get pinned key for a session if still valid.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} sessionId
|
|
182
|
+
* @returns {string|null}
|
|
183
|
+
*/
|
|
184
|
+
getSession(sessionId) {
|
|
185
|
+
if (!sessionId) return null;
|
|
186
|
+
const entry = this.sessions.get(sessionId);
|
|
187
|
+
if (!entry) return null;
|
|
188
|
+
const t = this.now();
|
|
189
|
+
if (t - entry.lastUsedAt > this.sessionTtlMs) {
|
|
190
|
+
this.sessions.delete(sessionId);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return entry.keyRef;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Unstick a specific session (e.g. after a failure or manual unpin).
|
|
198
|
+
*
|
|
199
|
+
* @param {string} sessionId
|
|
200
|
+
* @returns {boolean} true if session was present and removed
|
|
201
|
+
*/
|
|
202
|
+
unstick(sessionId) {
|
|
203
|
+
if (!sessionId) return false;
|
|
204
|
+
return this.sessions.delete(sessionId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Proactively unstick all sessions pinned to a failed / cooling key (#548).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} keyRef Failed key ref
|
|
211
|
+
* @returns {number} count of sessions unpinned
|
|
212
|
+
*/
|
|
213
|
+
unstickKey(keyRef) {
|
|
214
|
+
if (!keyRef) return 0;
|
|
215
|
+
let count = 0;
|
|
216
|
+
for (const [id, entry] of this.sessions.entries()) {
|
|
217
|
+
if (entry.keyRef === keyRef) {
|
|
218
|
+
this.sessions.delete(id);
|
|
219
|
+
count++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return count;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Return router diagnostics.
|
|
227
|
+
*/
|
|
228
|
+
getStatus() {
|
|
229
|
+
const t = this.now();
|
|
230
|
+
const counts = this.getKeyAssignmentCounts(t);
|
|
231
|
+
return {
|
|
232
|
+
activeSessionsCount: this.sessions.size,
|
|
233
|
+
maxSessions: this.maxSessions,
|
|
234
|
+
sessionTtlMs: this.sessionTtlMs,
|
|
235
|
+
keyAssignments: Object.fromEntries(counts.entries()),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reset all sessions (useful for tests).
|
|
241
|
+
*/
|
|
242
|
+
reset() {
|
|
243
|
+
this.sessions.clear();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Global default router instance for convenience
|
|
248
|
+
export const defaultSessionRouter = new SessionRouter();
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Helper to unstick all sessions pinned to keyRef across router or default instance (#548).
|
|
252
|
+
* @param {string|object} keyRefOrRouter
|
|
253
|
+
* @param {string} [keyRef]
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
export function unstickFailedKey(keyRefOrRouter, keyRef) {
|
|
257
|
+
if (typeof keyRefOrRouter === 'string') {
|
|
258
|
+
return defaultSessionRouter.unstickKey(keyRefOrRouter);
|
|
259
|
+
}
|
|
260
|
+
if (keyRefOrRouter instanceof SessionRouter) {
|
|
261
|
+
return keyRefOrRouter.unstickKey(keyRef);
|
|
262
|
+
}
|
|
263
|
+
return defaultSessionRouter.unstickKey(keyRef);
|
|
264
|
+
}
|
|
265
|
+
|
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.77",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|