@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.
@@ -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
- // Only actual content deltas lock the stream (no more rotation).
454
- // Structural/metadata chunks (block-start/end, usage) do not.
455
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
456
- yielded = true;
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
- yield chunk;
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
+
@@ -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 usedTokens = extractUsageTokens(usagePayload);
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
+ }