@goodandready/dsh-key-rotation 0.8.68 → 0.8.69
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 +20 -0
- package/lib/clone-lifecycle.js +199 -0
- package/lib/index.js +6 -0
- package/lib/key-drain.js +6 -4
- package/lib/ops-config-io.js +9 -2
- package/lib/pool-builder.js +8 -0
- package/lib/pool-selection.js +149 -0
- package/lib/pool.js +12 -145
- package/lib/rotate.js +595 -716
- package/lib/stream-metrics.js +133 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.69 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Automated Clone Route Lifecycle & Settings Synchronization (#502)**:
|
|
7
|
+
- Added `lib/clone-lifecycle.js` orchestrating automatic configuration, key pools, and parameter synchronization for auxiliary clone routes.
|
|
8
|
+
- Cloned worker routes dynamically inherit primary model, temperature, top-p, context window limits, and token buckets while isolating per-key rotation states.
|
|
9
|
+
- Supported graceful route drain via `key-drain.js` (`getInFlight` & `getActive`), ensuring active in-flight requests finish cleanly before clone route teardown.
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
- **Prompt Cache Periodic Maintenance Sweep (#511)**:
|
|
13
|
+
- Wired `compactPromptCache(pool, 30, now)` into the plugin's periodic maintenance sweep alongside `compactUsage`.
|
|
14
|
+
- Automatically purges prompt cache telemetry entries older than 30 days to prevent unbound memory growth during continuous production uptime.
|
|
15
|
+
|
|
16
|
+
### Refactoring & Code Quality
|
|
17
|
+
- **Core File Modularization (< 600 Lines Standard) (#512)**:
|
|
18
|
+
- Decomposed oversized monolithic modules to comply with `dsh-plugin-authoring` and `dsh-plugin-preflight` standards.
|
|
19
|
+
- Extracted key selection algorithms and sticky session routing from `lib/pool.js` into `lib/pool-selection.js` (bringing `pool.js` down from 654 to 521 lines).
|
|
20
|
+
- Extracted streaming success telemetry and proactive limit detection from `lib/rotate.js` into `lib/stream-metrics.js` and deduplicated cascade fallbacks (bringing `rotate.js` down from 716 to 595 lines).
|
|
21
|
+
- Decomposed monolithic UI card component `lib/client-src/50-card-main.js` (2,141 lines) into modular fragments: `50-card-main.js` (550 lines), `51-card-pool-mutators.js` (501 lines), `52-card-pool-rows.js` (590 lines), and `53-card-dialogs-and-render.js` (500 lines), ensuring 100% deterministic compilation and <600 lines for every source fragment.
|
|
22
|
+
|
|
3
23
|
## 0.8.68 - 2026-10-19
|
|
4
24
|
|
|
5
25
|
### Features
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// lib/clone-lifecycle.js — automated clone route lifecycle and settings synchronization (#502).
|
|
2
|
+
// Ensures virtual clone providers (provider-2, provider-3) automatically copy and sync
|
|
3
|
+
// baseUrl, headers, timeout, and models from the base provider, and graceful draining
|
|
4
|
+
// on key reduction before deregistering routes.
|
|
5
|
+
|
|
6
|
+
import { markKeyDraining, cancelKeyDrain, getDrainingStatus, isKeyDraining } from './key-drain.js';
|
|
7
|
+
|
|
8
|
+
export const CLONE_SYNC_FIELDS = [
|
|
9
|
+
'baseUrl',
|
|
10
|
+
'baseURL',
|
|
11
|
+
'headers',
|
|
12
|
+
'timeout',
|
|
13
|
+
'systemPrompt',
|
|
14
|
+
'customHeaders',
|
|
15
|
+
'models',
|
|
16
|
+
'protocol',
|
|
17
|
+
'apiType',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts syncable configuration from a base provider profile.
|
|
22
|
+
* @param {object} baseProfile
|
|
23
|
+
* @returns {object}
|
|
24
|
+
*/
|
|
25
|
+
export function extractSyncableConfig(baseProfile) {
|
|
26
|
+
if (!baseProfile || typeof baseProfile !== 'object') return {};
|
|
27
|
+
const syncable = {};
|
|
28
|
+
for (const field of CLONE_SYNC_FIELDS) {
|
|
29
|
+
if (baseProfile[field] !== undefined) {
|
|
30
|
+
syncable[field] = structuredClone(baseProfile[field]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return syncable;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Copies base provider configuration onto each clone profile.
|
|
38
|
+
* @param {object} baseProfile
|
|
39
|
+
* @param {object} cloneProfile
|
|
40
|
+
* @returns {object} merged clone profile
|
|
41
|
+
*/
|
|
42
|
+
export function syncCloneProfile(baseProfile, cloneProfile = {}) {
|
|
43
|
+
const syncable = extractSyncableConfig(baseProfile);
|
|
44
|
+
const out = {
|
|
45
|
+
...cloneProfile,
|
|
46
|
+
...syncable,
|
|
47
|
+
isClone: true,
|
|
48
|
+
};
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Synchronizes clone providers with their base provider settings.
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {string} opts.baseProvider
|
|
56
|
+
* @param {Set<string>|string[]} opts.cloneIds
|
|
57
|
+
* @param {object} opts.profiles live provider profiles map from settings
|
|
58
|
+
* @param {Function} [opts.onUpdate] callback when clone is updated
|
|
59
|
+
* @returns {{ updated: string[], cloned: Record<string, object> }}
|
|
60
|
+
*/
|
|
61
|
+
export function synchronizeClones({ baseProvider, cloneIds, profiles, onUpdate }) {
|
|
62
|
+
if (!baseProvider || !profiles || typeof profiles !== 'object') {
|
|
63
|
+
return { updated: [], cloned: {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const baseProfile = profiles[baseProvider];
|
|
67
|
+
if (!baseProfile) {
|
|
68
|
+
return { updated: [], cloned: {} };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const updated = [];
|
|
72
|
+
const cloned = {};
|
|
73
|
+
const ids = cloneIds instanceof Set ? [...cloneIds] : (Array.isArray(cloneIds) ? cloneIds : []);
|
|
74
|
+
|
|
75
|
+
for (const cloneId of ids) {
|
|
76
|
+
if (!cloneId.startsWith(`${baseProvider}-`)) continue;
|
|
77
|
+
const existing = profiles[cloneId] || {};
|
|
78
|
+
const synced = syncCloneProfile(baseProfile, existing);
|
|
79
|
+
cloned[cloneId] = synced;
|
|
80
|
+
updated.push(cloneId);
|
|
81
|
+
if (typeof onUpdate === 'function') {
|
|
82
|
+
onUpdate(cloneId, synced);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { updated, cloned };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Manages clone draining lifecycle when key count is reduced.
|
|
91
|
+
* Clones whose keys are removed are marked as draining, allowed to complete
|
|
92
|
+
* active in-flight streams, and safely deregistered only once in-flight reaches zero.
|
|
93
|
+
*/
|
|
94
|
+
export class CloneLifecycleManager {
|
|
95
|
+
/**
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {object} opts.concurrencyTracker
|
|
98
|
+
* @param {object} [opts.logger]
|
|
99
|
+
*/
|
|
100
|
+
constructor({ concurrencyTracker, logger } = {}) {
|
|
101
|
+
this.concurrencyTracker = concurrencyTracker;
|
|
102
|
+
this.logger = logger || console;
|
|
103
|
+
this.drainingClones = new Map(); // cloneId -> { provider, ref, startedAt, timer }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluates previous vs current expected clones for a provider.
|
|
108
|
+
* If a clone was removed, marks it draining instead of instantly destroying it.
|
|
109
|
+
* @param {string} provider
|
|
110
|
+
* @param {Set<string>|string[]} prevClones
|
|
111
|
+
* @param {Set<string>|string[]} currentClones
|
|
112
|
+
* @param {Function} onDeregister callback when draining completes and route is safe to remove
|
|
113
|
+
*/
|
|
114
|
+
reconcileClones(provider, prevClones, currentClones, onDeregister) {
|
|
115
|
+
const prevSet = prevClones instanceof Set ? prevClones : new Set(prevClones || []);
|
|
116
|
+
const currSet = currentClones instanceof Set ? currentClones : new Set(currentClones || []);
|
|
117
|
+
|
|
118
|
+
for (const cloneId of prevSet) {
|
|
119
|
+
if (!currSet.has(cloneId)) {
|
|
120
|
+
this.startDrain(cloneId, provider, onDeregister);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const cloneId of currSet) {
|
|
125
|
+
if (this.drainingClones.has(cloneId)) {
|
|
126
|
+
this.cancelDrain(cloneId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
startDrain(cloneId, provider, onDeregister) {
|
|
132
|
+
if (this.drainingClones.has(cloneId)) return;
|
|
133
|
+
|
|
134
|
+
markKeyDraining(cloneId, provider);
|
|
135
|
+
const entry = {
|
|
136
|
+
cloneId,
|
|
137
|
+
provider,
|
|
138
|
+
startedAt: Date.now(),
|
|
139
|
+
onDeregister,
|
|
140
|
+
};
|
|
141
|
+
this.drainingClones.set(cloneId, entry);
|
|
142
|
+
|
|
143
|
+
if (this.logger?.info) {
|
|
144
|
+
this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${provider}) marked draining; awaiting in-flight stream completion`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this._checkDrainCompletion(cloneId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_checkDrainCompletion(cloneId) {
|
|
151
|
+
const entry = this.drainingClones.get(cloneId);
|
|
152
|
+
if (!entry) return;
|
|
153
|
+
|
|
154
|
+
const inFlight = this.concurrencyTracker && typeof this.concurrencyTracker.getActive === 'function'
|
|
155
|
+
? this.concurrencyTracker.getActive(cloneId)
|
|
156
|
+
: (this.concurrencyTracker && typeof this.concurrencyTracker.getInFlight === 'function'
|
|
157
|
+
? this.concurrencyTracker.getInFlight(cloneId)
|
|
158
|
+
: 0);
|
|
159
|
+
|
|
160
|
+
if (inFlight === 0) {
|
|
161
|
+
this.drainingClones.delete(cloneId);
|
|
162
|
+
cancelKeyDrain(cloneId);
|
|
163
|
+
if (this.logger?.info) {
|
|
164
|
+
this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${entry.provider}) drained cleanly; safe to deregister`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof entry.onDeregister === 'function') {
|
|
167
|
+
entry.onDeregister(cloneId, entry.provider);
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// Re-check after 500ms
|
|
171
|
+
setTimeout(() => this._checkDrainCompletion(cloneId), 500).unref?.();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
cancelDrain(cloneId) {
|
|
176
|
+
this.drainingClones.delete(cloneId);
|
|
177
|
+
cancelKeyDrain(cloneId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
isDraining(cloneId) {
|
|
181
|
+
return this.drainingClones.has(cloneId) || isKeyDraining(cloneId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
getDrainingList() {
|
|
185
|
+
return Array.from(this.drainingClones.entries()).map(([cloneId, data]) => ({
|
|
186
|
+
cloneId,
|
|
187
|
+
provider: data.provider,
|
|
188
|
+
startedAt: data.startedAt,
|
|
189
|
+
elapsedMs: Date.now() - data.startedAt,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
destroy() {
|
|
194
|
+
for (const cloneId of this.drainingClones.keys()) {
|
|
195
|
+
cancelKeyDrain(cloneId);
|
|
196
|
+
}
|
|
197
|
+
this.drainingClones.clear();
|
|
198
|
+
}
|
|
199
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,8 @@ import { ConcurrencyTracker } from './concurrency.js';
|
|
|
22
22
|
import { QuotaStore } from './quota.js';
|
|
23
23
|
import { WebhookSender, AlertDebouncer } from './webhook.js';
|
|
24
24
|
import { compactUsage } from './usage-report.js';
|
|
25
|
+
import { compactPromptCache } from './prompt-cache.js';
|
|
26
|
+
import { CloneLifecycleManager, synchronizeClones } from './clone-lifecycle.js';
|
|
25
27
|
import { json, handleConfigBridge } from './http-bridge.js';
|
|
26
28
|
import { createRotate } from './rotate.js';
|
|
27
29
|
import { initializePoolState } from './pool-state.js';
|
|
@@ -200,6 +202,8 @@ export function apply(ctx, config = {}) {
|
|
|
200
202
|
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
201
203
|
const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
202
204
|
const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
|
|
205
|
+
const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger });
|
|
206
|
+
ctx.effect(() => () => { cloneLifecycle.destroy(); }, 'dsh-key-rotation: clone lifecycle cleanup');
|
|
203
207
|
|
|
204
208
|
const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
|
|
205
209
|
const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
@@ -243,6 +247,7 @@ export function apply(ctx, config = {}) {
|
|
|
243
247
|
if (n > 0) logger.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
244
248
|
for (const pool of (runtime.pools ?? runtime.poolByRef.values())) {
|
|
245
249
|
compactUsage(pool, 30, now);
|
|
250
|
+
compactPromptCache(pool, 30, now);
|
|
246
251
|
}
|
|
247
252
|
checkBudgetAndHealthAlerts({
|
|
248
253
|
runtime,
|
|
@@ -342,6 +347,7 @@ export function apply(ctx, config = {}) {
|
|
|
342
347
|
moduleBreaker,
|
|
343
348
|
lowHealthNotifiedAt,
|
|
344
349
|
budgetNotifiedAt,
|
|
350
|
+
cloneLifecycle,
|
|
345
351
|
});
|
|
346
352
|
|
|
347
353
|
concurrencyTracker.configure({ limit: concurrencyLimit });
|
package/lib/key-drain.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/key-drain.js — Managed key draining and graceful replacement (#463).
|
|
1
|
+
// lib/key-drain.js — Managed key draining and graceful replacement (#463, #502).
|
|
2
2
|
// Allows draining an old key ref (no new dispatches assigned) while letting
|
|
3
3
|
// in-flight active streams complete cleanly before permanent removal.
|
|
4
4
|
|
|
@@ -27,9 +27,11 @@ export function cancelKeyDrain(ref) {
|
|
|
27
27
|
export function getDrainingStatus(ref, concurrencyTracker) {
|
|
28
28
|
if (!ref || !drainingKeys.has(ref)) return null;
|
|
29
29
|
const entry = drainingKeys.get(ref);
|
|
30
|
-
const inFlight = concurrencyTracker && typeof concurrencyTracker.
|
|
31
|
-
? concurrencyTracker.
|
|
32
|
-
:
|
|
30
|
+
const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
|
|
31
|
+
? concurrencyTracker.getActive(ref)
|
|
32
|
+
: (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
|
|
33
|
+
? concurrencyTracker.getInFlight(ref)
|
|
34
|
+
: 0);
|
|
33
35
|
|
|
34
36
|
return {
|
|
35
37
|
ref,
|
package/lib/ops-config-io.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// lib/ops-config-io.js — full configuration export and import (#504).
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
let PLUGIN_VERSION = '0.8.68';
|
|
6
|
+
try {
|
|
7
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
8
|
+
if (pkg.version) PLUGIN_VERSION = pkg.version;
|
|
9
|
+
} catch (_) { /* fallback */ }
|
|
3
10
|
import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
|
|
4
11
|
import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
|
|
5
12
|
import { keyTail } from './pool.js';
|
|
@@ -172,7 +179,7 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
172
179
|
const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
|
|
173
180
|
json(res, 200, {
|
|
174
181
|
ok: true,
|
|
175
|
-
version:
|
|
182
|
+
version: PLUGIN_VERSION,
|
|
176
183
|
exportedAt: new Date().toISOString(),
|
|
177
184
|
masked,
|
|
178
185
|
encrypted: true,
|
|
@@ -183,7 +190,7 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
183
190
|
|
|
184
191
|
json(res, 200, {
|
|
185
192
|
ok: true,
|
|
186
|
-
version:
|
|
193
|
+
version: PLUGIN_VERSION,
|
|
187
194
|
exportedAt: new Date().toISOString(),
|
|
188
195
|
masked,
|
|
189
196
|
encrypted: false,
|
package/lib/pool-builder.js
CHANGED
|
@@ -248,6 +248,7 @@ export function cleanupRemovedProviders({
|
|
|
248
248
|
moduleBreaker,
|
|
249
249
|
lowHealthNotifiedAt,
|
|
250
250
|
budgetNotifiedAt,
|
|
251
|
+
cloneLifecycle,
|
|
251
252
|
}) {
|
|
252
253
|
const live = Array.isArray(pools) ? pools : [...(pools?.values?.() ?? (poolByRef?.values?.() ? [...poolByRef.values()] : []))];
|
|
253
254
|
const liveBases = new Set(live.map((p) => p.base));
|
|
@@ -281,4 +282,11 @@ export function cleanupRemovedProviders({
|
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
}
|
|
285
|
+
if (cloneLifecycle && typeof cloneLifecycle.reconcileClones === 'function') {
|
|
286
|
+
for (const p of cfg.providers ?? []) {
|
|
287
|
+
const liveClones = new Set([...expectedClones].filter(c => c.startsWith(`${p.provider}-`)));
|
|
288
|
+
cloneLifecycle.reconcileClones(p.provider, poolState.get(`${p.provider}:clones`) ?? [], liveClones);
|
|
289
|
+
poolState.set(`${p.provider}:clones`, liveClones);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
284
292
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// lib/pool-selection.js — pool selection, sticky sessions, attempt sorting and budget metrics (#512).
|
|
2
|
+
// Extracted from lib/pool.js to enforce < 600 line modularity standard.
|
|
3
|
+
|
|
4
|
+
export const MAX_SESSION_STICKY_ENTRIES = 500;
|
|
5
|
+
|
|
6
|
+
export function setSessionSticky(pool, sessionId, candidate) {
|
|
7
|
+
if (!sessionId || !candidate || !pool?.state) return;
|
|
8
|
+
if (!(pool.state.sessionSticky instanceof Map)) {
|
|
9
|
+
pool.state.sessionSticky = new Map();
|
|
10
|
+
}
|
|
11
|
+
if (pool.state.sessionSticky.has(sessionId)) {
|
|
12
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
13
|
+
} else if (pool.state.sessionSticky.size >= MAX_SESSION_STICKY_ENTRIES) {
|
|
14
|
+
const oldest = pool.state.sessionSticky.keys().next().value;
|
|
15
|
+
if (oldest !== undefined) pool.state.sessionSticky.delete(oldest);
|
|
16
|
+
}
|
|
17
|
+
pool.state.sessionSticky.set(sessionId, candidate);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSessionSticky(pool, sessionId) {
|
|
21
|
+
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
22
|
+
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearSessionSticky(pool, sessionId) {
|
|
26
|
+
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
27
|
+
pool.state.sessionSticky.delete(sessionId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pick a key pool for a (provider, model) pair. */
|
|
31
|
+
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
32
|
+
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
33
|
+
const base = providerToPool && providerToPool.get(provider);
|
|
34
|
+
if (!byModel || !model) return base ?? null;
|
|
35
|
+
if (byModel.has(model)) return byModel.get(model);
|
|
36
|
+
let best = null;
|
|
37
|
+
for (const key of byModel.keys()) {
|
|
38
|
+
if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
|
|
39
|
+
}
|
|
40
|
+
return (best ? byModel.get(best) : base) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sort candidate refs according to pool routing strategy.
|
|
45
|
+
* Returns a new array; does not mutate input.
|
|
46
|
+
*/
|
|
47
|
+
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
48
|
+
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
49
|
+
const list = refs.slice();
|
|
50
|
+
if (strategy === 'sticky') {
|
|
51
|
+
const last = deps.sessionSticky ||
|
|
52
|
+
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
53
|
+
deps.lastUsed ||
|
|
54
|
+
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
55
|
+
if (last && list.includes(last)) {
|
|
56
|
+
const idx = list.indexOf(last);
|
|
57
|
+
if (idx > 0) {
|
|
58
|
+
list.splice(idx, 1);
|
|
59
|
+
list.unshift(last);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return list;
|
|
63
|
+
}
|
|
64
|
+
if (strategy === 'tier-priority') {
|
|
65
|
+
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
66
|
+
return list.sort((a, b) => {
|
|
67
|
+
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
68
|
+
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
69
|
+
return ta - tb;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
73
|
+
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
74
|
+
return list.sort((a, b) => {
|
|
75
|
+
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
76
|
+
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
77
|
+
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
78
|
+
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
79
|
+
const scoreA = ca / wa;
|
|
80
|
+
const scoreB = cb / wb;
|
|
81
|
+
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
82
|
+
return ca - cb;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
86
|
+
return list.sort((a, b) => {
|
|
87
|
+
const sa = deps.latencyHistogram.snapshot(a);
|
|
88
|
+
const sb = deps.latencyHistogram.snapshot(b);
|
|
89
|
+
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
90
|
+
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
91
|
+
return la - lb;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return list;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
99
|
+
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
100
|
+
*/
|
|
101
|
+
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
102
|
+
if (!pool || !pool.expiresAt) return [];
|
|
103
|
+
const DAY_MS = 86400000;
|
|
104
|
+
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
105
|
+
return Object.entries(pool.expiresAt)
|
|
106
|
+
.filter(([, at]) => at > now && at <= horizon)
|
|
107
|
+
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
108
|
+
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Dedupe: true when a day-level notification for key is due. */
|
|
112
|
+
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
113
|
+
const DAY_MS = 86400000;
|
|
114
|
+
if (!lastNotified.has(key)) {
|
|
115
|
+
lastNotified.set(key, now);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const last = lastNotified.get(key);
|
|
119
|
+
if (now - last < DAY_MS) return false;
|
|
120
|
+
lastNotified.set(key, now);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
125
|
+
export function costForDay(costDays, day) {
|
|
126
|
+
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
127
|
+
let total = 0;
|
|
128
|
+
for (const perRef of (costDays?.values() ?? [])) {
|
|
129
|
+
total += perRef.get(d) ?? 0;
|
|
130
|
+
}
|
|
131
|
+
return total;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Total spend over the last 7 ISO days ending today. */
|
|
135
|
+
export function costForWeek(costDays, now = Date.now()) {
|
|
136
|
+
let total = 0;
|
|
137
|
+
for (let i = 0; i < 7; i++) {
|
|
138
|
+
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
139
|
+
total += costForDay(costDays, d);
|
|
140
|
+
}
|
|
141
|
+
return total;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
145
|
+
export function budgetVerdict(spend, budget) {
|
|
146
|
+
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
147
|
+
const ratio = spend / budget;
|
|
148
|
+
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
149
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
pool.state.sessionSticky.set(sessionId, candidate);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getSessionSticky(pool, sessionId) {
|
|
18
|
-
if (!sessionId || !pool?.state?.sessionSticky) return null;
|
|
19
|
-
return pool.state.sessionSticky.get(sessionId) ?? null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function clearSessionSticky(pool, sessionId) {
|
|
23
|
-
if (!sessionId || !pool?.state?.sessionSticky) return;
|
|
24
|
-
pool.state.sessionSticky.delete(sessionId);
|
|
25
|
-
}
|
|
1
|
+
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
2
|
+
import {
|
|
3
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
4
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
5
|
+
costForDay, costForWeek, budgetVerdict,
|
|
6
|
+
} from './pool-selection.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
|
|
10
|
+
selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
|
|
11
|
+
costForDay, costForWeek, budgetVerdict,
|
|
12
|
+
};
|
|
26
13
|
|
|
27
14
|
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
28
15
|
import { decryptSecret } from './crypto-storage.js';
|
|
@@ -288,19 +275,6 @@ export function parseRetryAfter(value) {
|
|
|
288
275
|
}
|
|
289
276
|
|
|
290
277
|
/** Pick a key pool for a (provider, model) pair. */
|
|
291
|
-
export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
|
|
292
|
-
const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
|
|
293
|
-
const base = providerToPool && providerToPool.get(provider);
|
|
294
|
-
if (!byModel || !model) return base ?? null;
|
|
295
|
-
if (byModel.has(model)) return byModel.get(model);
|
|
296
|
-
let best = null;
|
|
297
|
-
for (const key of byModel.keys()) {
|
|
298
|
-
if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
|
|
299
|
-
}
|
|
300
|
-
return (best ? byModel.get(best) : base) ?? null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
|
|
304
278
|
export function parseExpiry(v) {
|
|
305
279
|
if (typeof v === 'number' && v > 0) return v;
|
|
306
280
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
@@ -434,61 +408,6 @@ export function isRateLimited(rate, threshold = 0.1) {
|
|
|
434
408
|
* - 'least-loaded': keys with lowest active concurrency count first
|
|
435
409
|
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
436
410
|
*/
|
|
437
|
-
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
438
|
-
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
439
|
-
const list = refs.slice();
|
|
440
|
-
if (strategy === 'sticky') {
|
|
441
|
-
const last = deps.sessionSticky ||
|
|
442
|
-
(deps.sessionId && (deps.pool?.state?.sessionSticky?.get?.(deps.sessionId) || deps.sessionStickyMap?.get?.(deps.sessionId))) ||
|
|
443
|
-
deps.lastUsed ||
|
|
444
|
-
(deps.pool && deps.pool.state && deps.pool.state.lastUsed);
|
|
445
|
-
if (last && list.includes(last)) {
|
|
446
|
-
const idx = list.indexOf(last);
|
|
447
|
-
if (idx > 0) {
|
|
448
|
-
list.splice(idx, 1);
|
|
449
|
-
list.unshift(last);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return list;
|
|
453
|
-
}
|
|
454
|
-
if (strategy === 'tier-priority') {
|
|
455
|
-
const tiers = deps.tiers || (deps.pool && deps.pool.tiers) || {};
|
|
456
|
-
return list.sort((a, b) => {
|
|
457
|
-
const ta = (tiers[a] && Number(tiers[a]) >= 1) ? Number(tiers[a]) : 1;
|
|
458
|
-
const tb = (tiers[b] && Number(tiers[b]) >= 1) ? Number(tiers[b]) : 1;
|
|
459
|
-
return ta - tb;
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
463
|
-
const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
|
|
464
|
-
return list.sort((a, b) => {
|
|
465
|
-
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
466
|
-
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
467
|
-
const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
|
|
468
|
-
const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
|
|
469
|
-
const scoreA = ca / wa;
|
|
470
|
-
const scoreB = cb / wb;
|
|
471
|
-
if (scoreA !== scoreB) return scoreA - scoreB;
|
|
472
|
-
return ca - cb;
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
476
|
-
return list.sort((a, b) => {
|
|
477
|
-
const sa = deps.latencyHistogram.snapshot(a);
|
|
478
|
-
const sb = deps.latencyHistogram.snapshot(b);
|
|
479
|
-
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
480
|
-
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
481
|
-
return la - lb;
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return list;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Unified check whether an error or payload represents a switchable failure.
|
|
489
|
-
* Prioritizes explicit HTTP status codes and gRPC codes before text matching.
|
|
490
|
-
*/
|
|
491
|
-
/** Raw socket-level codes the taxonomy maps to TRANSPORT (see classifyFailure). */
|
|
492
411
|
const SOCKET_TRANSPORT_CODES = new Set([
|
|
493
412
|
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
|
|
494
413
|
'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
|
|
@@ -565,58 +484,6 @@ export function formatExhaustionMessage(provider, pool, now = Date.now()) {
|
|
|
565
484
|
* Keys of pool whose expiresAt falls within the next warnDays.
|
|
566
485
|
* Returns [{ ref, expiresInDays, expiresAt }], soonest first.
|
|
567
486
|
*/
|
|
568
|
-
export function expiringSoon(pool, warnDays = 7, now = Date.now()) {
|
|
569
|
-
if (!pool || !pool.expiresAt) return [];
|
|
570
|
-
const DAY_MS = 86400000;
|
|
571
|
-
const horizon = now + Math.max(1, warnDays) * DAY_MS;
|
|
572
|
-
return Object.entries(pool.expiresAt)
|
|
573
|
-
.filter(([, at]) => at > now && at <= horizon)
|
|
574
|
-
.map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
|
|
575
|
-
.sort((a, b) => a.expiresAt - b.expiresAt);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** Dedupe: true when a day-level notification for key is due. */
|
|
579
|
-
export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
|
|
580
|
-
const DAY_MS = 86400000;
|
|
581
|
-
if (!lastNotified.has(key)) {
|
|
582
|
-
lastNotified.set(key, now);
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
const last = lastNotified.get(key);
|
|
586
|
-
if (now - last < DAY_MS) return false;
|
|
587
|
-
lastNotified.set(key, now);
|
|
588
|
-
return true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Total spend of a pool on ISO day day (defaults to today) across costDays Map<ref, Map<day, cost>>. */
|
|
592
|
-
export function costForDay(costDays, day) {
|
|
593
|
-
const d = day ?? new Date().toISOString().slice(0, 10);
|
|
594
|
-
let total = 0;
|
|
595
|
-
for (const perRef of (costDays?.values() ?? [])) {
|
|
596
|
-
total += perRef.get(d) ?? 0;
|
|
597
|
-
}
|
|
598
|
-
return total;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** Total spend over the last 7 ISO days ending today. */
|
|
602
|
-
export function costForWeek(costDays, now = Date.now()) {
|
|
603
|
-
let total = 0;
|
|
604
|
-
for (let i = 0; i < 7; i++) {
|
|
605
|
-
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
606
|
-
total += costForDay(costDays, d);
|
|
607
|
-
}
|
|
608
|
-
return total;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Budget verdict for a pool: { spend, budget, ratio, warn, exceeded }. */
|
|
612
|
-
export function budgetVerdict(spend, budget) {
|
|
613
|
-
if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
|
|
614
|
-
const ratio = spend / budget;
|
|
615
|
-
return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
/** Safely reset a provider's circuit breaker to closed state. */
|
|
620
487
|
export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
621
488
|
if (!circuitBreaker || !provider) return false;
|
|
622
489
|
const ok = bestEffort('resetCircuitForProvider', () => {
|