@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.
@@ -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.75",
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",