@goodandready/dsh-key-rotation 0.8.76 → 0.8.78
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-src/00-header.js +40 -0
- package/lib/client-src/10-locales.js +585 -0
- package/lib/client-src/20-hooks.js +55 -0
- package/lib/client-src/30-utils.js +185 -0
- package/lib/client-src/40-components.js +346 -0
- package/lib/client-src/50-card-main.js +647 -0
- package/lib/client-src/51-card-pool-mutators.js +501 -0
- package/lib/client-src/52-card-pool-rows.js +590 -0
- package/lib/client-src/53-card-dialogs-and-render.js +568 -0
- package/lib/client-src/60-header-chip.js +137 -0
- package/lib/client-src/90-apply.js +69 -0
- package/lib/error-taxonomy.js +59 -10
- package/lib/errors.js +302 -0
- package/lib/index.js +6 -0
- package/lib/model-catalog.js +296 -0
- package/lib/pool-selection.js +126 -28
- package/lib/pool.js +19 -0
- package/lib/quota-runway.js +222 -0
- package/lib/rotate.js +61 -11
- package/lib/session-router.js +265 -0
- package/lib/stream-metrics.js +13 -5
- package/lib/transports/anthropic.js +125 -0
- package/lib/transports/index.js +23 -0
- package/lib/transports/openai.js +93 -0
- package/lib/transports/sse.js +160 -0
- package/lib/transports/tokens.js +124 -0
- package/package.json +5 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// lib/quota-runway.js — Predictive Quota Runway and burn rate estimation (#549).
|
|
2
|
+
// Estimates time to quota exhaustion and preemptively rotates keys before hitting HTTP 429.
|
|
3
|
+
|
|
4
|
+
export const MINUTE = 60 * 1000;
|
|
5
|
+
export const HOUR = 60 * MINUTE;
|
|
6
|
+
export const DAY = 24 * HOUR;
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_WINDOW_DURATIONS = {
|
|
9
|
+
minute: MINUTE,
|
|
10
|
+
fiveMinute: 5 * MINUTE,
|
|
11
|
+
hourly: HOUR,
|
|
12
|
+
daily: DAY,
|
|
13
|
+
monthly: 30 * DAY,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bounds percentage value to [0, 100].
|
|
18
|
+
* @param {number|unknown} value
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
export function clampPercent(value) {
|
|
22
|
+
const num = Number(value);
|
|
23
|
+
return Number.isFinite(num) ? Math.max(0, Math.min(100, num)) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Estimates when a quota window reaches 100% at the average burn rate observed.
|
|
28
|
+
*
|
|
29
|
+
* @param {object|undefined} window
|
|
30
|
+
* @param {number} window.percentUsed Current percentage used (0..100)
|
|
31
|
+
* @param {string|number} window.resetsAt ISO timestamp or epoch ms when window resets
|
|
32
|
+
* @param {number} durationMs Window duration in milliseconds
|
|
33
|
+
* @param {number} [now] Current epoch timestamp in milliseconds
|
|
34
|
+
* @returns {{
|
|
35
|
+
* etaMs: number,
|
|
36
|
+
* resetsAtMs: number,
|
|
37
|
+
* exhaustsBeforeReset: boolean,
|
|
38
|
+
* burnRatePerSec: number
|
|
39
|
+
* } | null}
|
|
40
|
+
*/
|
|
41
|
+
export function estimateRunway(window, durationMs, now = Date.now()) {
|
|
42
|
+
if (!window || window.resetsAt == null || !durationMs || durationMs <= 0) return null;
|
|
43
|
+
|
|
44
|
+
const resetsAtMs = typeof window.resetsAt === 'number' ? window.resetsAt : Date.parse(window.resetsAt);
|
|
45
|
+
if (!Number.isFinite(resetsAtMs) || resetsAtMs <= now) return null;
|
|
46
|
+
|
|
47
|
+
const used = clampPercent(window.percentUsed);
|
|
48
|
+
const timeToReset = resetsAtMs - now;
|
|
49
|
+
|
|
50
|
+
// Already 100% or more: exhausts immediately
|
|
51
|
+
if (used >= 100) {
|
|
52
|
+
return {
|
|
53
|
+
etaMs: 0,
|
|
54
|
+
resetsAtMs,
|
|
55
|
+
exhaustsBeforeReset: true,
|
|
56
|
+
burnRatePerSec: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startedAt = resetsAtMs - durationMs;
|
|
61
|
+
const elapsed = Math.max(1, now - startedAt);
|
|
62
|
+
const ratePerMs = used / elapsed;
|
|
63
|
+
|
|
64
|
+
if (!Number.isFinite(ratePerMs) || ratePerMs <= 0) {
|
|
65
|
+
return {
|
|
66
|
+
etaMs: Number.POSITIVE_INFINITY,
|
|
67
|
+
resetsAtMs,
|
|
68
|
+
exhaustsBeforeReset: false,
|
|
69
|
+
burnRatePerSec: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const remainingPercent = 100 - used;
|
|
74
|
+
const etaMs = remainingPercent / ratePerMs;
|
|
75
|
+
const exhaustsBeforeReset = etaMs < timeToReset;
|
|
76
|
+
const burnRatePerSec = ratePerMs * 1000;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
etaMs,
|
|
80
|
+
resetsAtMs,
|
|
81
|
+
exhaustsBeforeReset,
|
|
82
|
+
burnRatePerSec,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stateful Quota Runway tracker for credentials and pools.
|
|
88
|
+
*/
|
|
89
|
+
export class QuotaRunway {
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {number} [options.slidingWindowMs]
|
|
93
|
+
*/
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
this.slidingWindowMs = options.slidingWindowMs ?? (5 * MINUTE);
|
|
96
|
+
/** @type {Map<string, Array<{ at: number, amount: number }>>} */
|
|
97
|
+
this.samples = new Map();
|
|
98
|
+
/** @type {Map<string, { limit: number, remaining: number, resetsAtMs: number, durationMs: number }>} */
|
|
99
|
+
this.knownLimits = new Map();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records usage tokens or requests for a key.
|
|
104
|
+
* @param {string} keyRef
|
|
105
|
+
* @param {number} amount
|
|
106
|
+
* @param {number} [now]
|
|
107
|
+
*/
|
|
108
|
+
recordUsage(keyRef, amount, now = Date.now()) {
|
|
109
|
+
if (!keyRef || !amount || amount <= 0) return;
|
|
110
|
+
let list = this.samples.get(keyRef);
|
|
111
|
+
if (!list) {
|
|
112
|
+
list = [];
|
|
113
|
+
this.samples.set(keyRef, list);
|
|
114
|
+
}
|
|
115
|
+
list.push({ at: now, amount });
|
|
116
|
+
this._prune(keyRef, now);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Updates key limits detected from response headers or provider metadata.
|
|
121
|
+
* @param {string} keyRef
|
|
122
|
+
* @param {object} limits
|
|
123
|
+
* @param {number} [limits.limit]
|
|
124
|
+
* @param {number} [limits.remaining]
|
|
125
|
+
* @param {number|string} [limits.resetsAt]
|
|
126
|
+
* @param {number} [limits.durationMs]
|
|
127
|
+
* @param {number} [now]
|
|
128
|
+
*/
|
|
129
|
+
updateLimits(keyRef, limits, now = Date.now()) {
|
|
130
|
+
if (!keyRef || !limits) return;
|
|
131
|
+
const limit = Number(limits.tpmLimit ?? limits.rpmLimit ?? limits.limit);
|
|
132
|
+
const remaining = Number(limits.remainingTokens ?? limits.remainingRequests ?? limits.remaining);
|
|
133
|
+
const resetSec = Number(limits.resetTokens ?? limits.resetRequests ?? limits.retryAfter);
|
|
134
|
+
|
|
135
|
+
let resetsAtMs = NaN;
|
|
136
|
+
if (Number.isFinite(resetSec)) {
|
|
137
|
+
resetsAtMs = now + (resetSec * 1000);
|
|
138
|
+
} else if (limits.resetsAt || limits.resetAt) {
|
|
139
|
+
const at = limits.resetsAt || limits.resetAt;
|
|
140
|
+
resetsAtMs = typeof at === 'number' ? at : Date.parse(at);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const durationMs = Number(limits.durationMs) || DEFAULT_WINDOW_DURATIONS.minute;
|
|
144
|
+
|
|
145
|
+
if (Number.isFinite(limit) && Number.isFinite(remaining) && Number.isFinite(resetsAtMs)) {
|
|
146
|
+
this.knownLimits.set(keyRef, {
|
|
147
|
+
limit,
|
|
148
|
+
remaining,
|
|
149
|
+
resetsAtMs,
|
|
150
|
+
durationMs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checks if key is projected to exhaust its quota before the current window resets.
|
|
157
|
+
* @param {string} keyRef
|
|
158
|
+
* @param {number} [now]
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
isExhaustingBeforeReset(keyRef, now = Date.now()) {
|
|
162
|
+
const assessment = this.assessRunway(keyRef, now);
|
|
163
|
+
return Boolean(assessment?.exhaustsBeforeReset);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns complete runway assessment for a key.
|
|
168
|
+
* @param {string} keyRef
|
|
169
|
+
* @param {number} [now]
|
|
170
|
+
* @returns {{
|
|
171
|
+
* exhaustsBeforeReset: boolean,
|
|
172
|
+
* etaMs: number,
|
|
173
|
+
* burnRatePerSec: number,
|
|
174
|
+
* timeToResetMs: number
|
|
175
|
+
* } | null}
|
|
176
|
+
*/
|
|
177
|
+
assessRunway(keyRef, now = Date.now()) {
|
|
178
|
+
if (!keyRef) return null;
|
|
179
|
+
|
|
180
|
+
const limits = this.knownLimits.get(keyRef);
|
|
181
|
+
if (!limits || limits.resetsAtMs <= now) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { limit, remaining, resetsAtMs, durationMs } = limits;
|
|
186
|
+
if (limit <= 0) return null;
|
|
187
|
+
|
|
188
|
+
const used = Math.max(0, limit - remaining);
|
|
189
|
+
const percentUsed = (used / limit) * 100;
|
|
190
|
+
|
|
191
|
+
const estimate = estimateRunway(
|
|
192
|
+
{ percentUsed, resetsAt: resetsAtMs },
|
|
193
|
+
durationMs,
|
|
194
|
+
now,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!estimate) return null;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
exhaustsBeforeReset: estimate.exhaustsBeforeReset,
|
|
201
|
+
etaMs: estimate.etaMs,
|
|
202
|
+
burnRatePerSec: estimate.burnRatePerSec,
|
|
203
|
+
timeToResetMs: resetsAtMs - now,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_prune(keyRef, now) {
|
|
208
|
+
const list = this.samples.get(keyRef);
|
|
209
|
+
if (!list) return;
|
|
210
|
+
const cutoff = now - this.slidingWindowMs;
|
|
211
|
+
while (list.length > 0 && list[0].at < cutoff) {
|
|
212
|
+
list.shift();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
clear() {
|
|
217
|
+
this.samples.clear();
|
|
218
|
+
this.knownLimits.clear();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const defaultQuotaRunway = new QuotaRunway();
|
package/lib/rotate.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getSessionSticky,
|
|
16
16
|
setSessionSticky,
|
|
17
17
|
clearSessionSticky,
|
|
18
|
+
unstickFailedKey,
|
|
18
19
|
} from './pool.js';
|
|
19
20
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
20
21
|
import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
|
|
@@ -210,6 +211,7 @@ export function createRotate(deps) {
|
|
|
210
211
|
const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
|
|
211
212
|
if (!targetRef) return;
|
|
212
213
|
if (sessionId) clearSessionSticky(pool, sessionId);
|
|
214
|
+
unstickFailedKey(pool, targetRef);
|
|
213
215
|
if (errCode === LOCAL_QUOTA_CODE) return;
|
|
214
216
|
let headerResetMs = null;
|
|
215
217
|
if (rawHeaders) {
|
|
@@ -411,6 +413,7 @@ export function createRotate(deps) {
|
|
|
411
413
|
let yielded = false;
|
|
412
414
|
let switching = false;
|
|
413
415
|
let inner;
|
|
416
|
+
const preTokenBuffer = [];
|
|
414
417
|
try {
|
|
415
418
|
// #464 & #461: Record attempt & trace dispatch
|
|
416
419
|
budget.recordAttempt();
|
|
@@ -450,10 +453,13 @@ export function createRotate(deps) {
|
|
|
450
453
|
if (ok) reqStore.concurrencyRef = fallbackRef;
|
|
451
454
|
}
|
|
452
455
|
}
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
456
|
+
// Pre-First-Token Failover (#527): Lock the stream only upon first content delta.
|
|
457
|
+
if (chunk && isContentChunk(chunk)) {
|
|
458
|
+
if (!yielded) {
|
|
459
|
+
yielded = true;
|
|
460
|
+
for (const prev of preTokenBuffer) yield prev;
|
|
461
|
+
preTokenBuffer.length = 0;
|
|
462
|
+
}
|
|
457
463
|
yield chunk;
|
|
458
464
|
continue;
|
|
459
465
|
}
|
|
@@ -465,15 +471,16 @@ export function createRotate(deps) {
|
|
|
465
471
|
const message = failure?.message ?? '';
|
|
466
472
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
467
473
|
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
468
|
-
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
474
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
|
|
469
475
|
if (switchable) {
|
|
476
|
+
preTokenBuffer.length = 0; // Discard metadata from aborted attempt (#527)
|
|
470
477
|
penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
471
478
|
recordFailureCategory(pool, code ?? 'UNKNOWN', message, activeRef, now());
|
|
472
479
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
473
480
|
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
474
481
|
pool.state.lastSwitchAt = now();
|
|
475
482
|
lastFailure = chunk;
|
|
476
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
483
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed before first token (${String(code)} ${String(message).slice(0, 100)}) - failover to next key`);
|
|
477
484
|
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
478
485
|
if (switchNotify && activeRef) {
|
|
479
486
|
notifySwitch(runtime0, pool, {
|
|
@@ -492,6 +499,10 @@ export function createRotate(deps) {
|
|
|
492
499
|
if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
|
|
493
500
|
penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
494
501
|
}
|
|
502
|
+
if (!yielded) {
|
|
503
|
+
for (const prev of preTokenBuffer) yield prev;
|
|
504
|
+
preTokenBuffer.length = 0;
|
|
505
|
+
}
|
|
495
506
|
yield chunk;
|
|
496
507
|
return;
|
|
497
508
|
}
|
|
@@ -539,24 +550,33 @@ export function createRotate(deps) {
|
|
|
539
550
|
});
|
|
540
551
|
}
|
|
541
552
|
recordLatency(pool, reqStore);
|
|
542
|
-
activePermit?.success();
|
|
553
|
+
if (typeof activePermit?.success === 'function') activePermit.success();
|
|
543
554
|
if (isTraceOwner) trace.finish('SUCCESS', activeRef);
|
|
555
|
+
if (!yielded) {
|
|
556
|
+
for (const prev of preTokenBuffer) yield prev;
|
|
557
|
+
preTokenBuffer.length = 0;
|
|
558
|
+
}
|
|
544
559
|
yield chunk;
|
|
545
560
|
return;
|
|
546
561
|
}
|
|
547
|
-
|
|
562
|
+
if (yielded) {
|
|
563
|
+
yield chunk;
|
|
564
|
+
} else {
|
|
565
|
+
preTokenBuffer.push(chunk);
|
|
566
|
+
}
|
|
548
567
|
}
|
|
549
568
|
} catch (e) {
|
|
550
569
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
551
|
-
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
570
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
|
|
552
571
|
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
572
|
+
preTokenBuffer.length = 0; // Discard metadata from failed attempt (#527)
|
|
553
573
|
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
554
574
|
recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
|
|
555
575
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
556
576
|
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
557
577
|
pool.state.lastSwitchAt = now();
|
|
558
578
|
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
559
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
579
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
560
580
|
if (switchNotify && activeRef) {
|
|
561
581
|
notifySwitch(runtime0, pool, {
|
|
562
582
|
provider: options.provider,
|
|
@@ -570,16 +590,24 @@ export function createRotate(deps) {
|
|
|
570
590
|
if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
571
591
|
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
|
|
572
592
|
}
|
|
593
|
+
if (!yielded) {
|
|
594
|
+
for (const prev of preTokenBuffer) yield prev;
|
|
595
|
+
preTokenBuffer.length = 0;
|
|
596
|
+
}
|
|
573
597
|
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
574
598
|
return;
|
|
575
599
|
}
|
|
576
600
|
|
|
577
601
|
if (switching) continue; // try the next key
|
|
602
|
+
if (!yielded && preTokenBuffer.length > 0) {
|
|
603
|
+
for (const prev of preTokenBuffer) yield prev;
|
|
604
|
+
preTokenBuffer.length = 0;
|
|
605
|
+
}
|
|
578
606
|
if (yielded) {
|
|
579
607
|
const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
|
|
580
608
|
if (activeRef) recordSuccess(pool, activeRef, now());
|
|
581
609
|
recordLatency(pool, reqStore);
|
|
582
|
-
activePermit?.success();
|
|
610
|
+
if (typeof activePermit?.success === 'function') activePermit.success();
|
|
583
611
|
if (isTraceOwner) trace.finish('SUCCESS', activeRef);
|
|
584
612
|
} else if (isTraceOwner) {
|
|
585
613
|
trace.finish('COMPLETED', reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed);
|
|
@@ -626,3 +654,25 @@ export function createRotate(deps) {
|
|
|
626
654
|
|
|
627
655
|
return rotate;
|
|
628
656
|
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether a chunk carries a model-visible content delta (#527).
|
|
660
|
+
* @param {Record<string, unknown>} chunk
|
|
661
|
+
* @returns {boolean}
|
|
662
|
+
*/
|
|
663
|
+
export function isContentChunk(chunk) {
|
|
664
|
+
if (!chunk || typeof chunk !== 'object') return false;
|
|
665
|
+
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta') {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
if (chunk.delta !== undefined || chunk.text !== undefined || chunk.content !== undefined || chunk.choices !== undefined) {
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
if (chunk.type === 'start' || chunk.type === 'block-start' || chunk.type === 'block-stop' ||
|
|
672
|
+
chunk.type === 'metadata' || chunk.type === 'response-metadata' || chunk.type === 'usage' ||
|
|
673
|
+
chunk.type === 'finish') {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
|
|
@@ -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/lib/stream-metrics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/stream-metrics.js — streaming success telemetry, usage, cost and token recording (#512).
|
|
1
|
+
// lib/stream-metrics.js — streaming success telemetry, usage, cost and token recording (#512, #546, #549).
|
|
2
2
|
// Extracted from lib/rotate.js to enforce < 600 line modularity standard.
|
|
3
3
|
|
|
4
4
|
import { recordSuccess, setSessionSticky, recordFailure, extractRateLimit, isRateLimited } from './pool.js';
|
|
@@ -7,6 +7,7 @@ import { recordRequestOutcome } from './ops-analytics.js';
|
|
|
7
7
|
import { tpmRecord } from './bucket.js';
|
|
8
8
|
import { extractUsageTokens, consumeModelTokens } from './model-quota.js';
|
|
9
9
|
import { extractProviderLimits } from './provider-limits-detector.js';
|
|
10
|
+
import { normalizeUsageTokens } from './transports/tokens.js';
|
|
10
11
|
|
|
11
12
|
export async function* preserveDispatchContext(iterable, storage, store) {
|
|
12
13
|
if (!storage || typeof storage.getStore !== 'function') {
|
|
@@ -68,11 +69,15 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
68
69
|
cascaded: Boolean(options.__isCascade),
|
|
69
70
|
}, wallNow);
|
|
70
71
|
|
|
71
|
-
// 3. TPM sliding window bucket recording
|
|
72
|
-
const
|
|
72
|
+
// 3. TPM sliding window bucket recording & token normalization (#546, #549)
|
|
73
|
+
const normUsage = normalizeUsageTokens(usagePayload);
|
|
74
|
+
const usedTokens = normUsage.totalTokens > 0 ? normUsage.totalTokens : extractUsageTokens(usagePayload);
|
|
73
75
|
if (usedTokens && usedTokens > 0) {
|
|
74
76
|
if (!pool.state.tpmWindows) pool.state.tpmWindows = new Map();
|
|
75
77
|
tpmRecord(pool.state.tpmWindows, activeRef, usedTokens, wallNow);
|
|
78
|
+
if (pool.state.quotaRunway && typeof pool.state.quotaRunway.recordUsage === 'function') {
|
|
79
|
+
pool.state.quotaRunway.recordUsage(activeRef, usedTokens, wallNow);
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
// 4. Local model quota consumption
|
|
@@ -91,12 +96,15 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
// 5. Provider rate limit headers detection (#503)
|
|
99
|
+
// 5. Provider rate limit headers detection & predictive runway (#503, #549)
|
|
95
100
|
if (rawHdrs) {
|
|
96
101
|
const detected = extractProviderLimits(rawHdrs);
|
|
97
102
|
if (detected) {
|
|
98
103
|
if (!pool.state.detectedLimits) pool.state.detectedLimits = new Map();
|
|
99
104
|
pool.state.detectedLimits.set(activeRef, { ...detected, at: nowMs });
|
|
105
|
+
if (pool.state.quotaRunway && typeof pool.state.quotaRunway.updateLimits === 'function') {
|
|
106
|
+
pool.state.quotaRunway.updateLimits(activeRef, detected, wallNow);
|
|
107
|
+
}
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -130,4 +138,4 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
130
138
|
if (rate && Number.isFinite(rate.remaining) && quotaStore) {
|
|
131
139
|
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: nowMs });
|
|
132
140
|
}
|
|
133
|
-
}
|
|
141
|
+
}
|