@goodandready/dsh-key-rotation 0.8.74 → 0.8.75

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/lib/rotate.js CHANGED
@@ -1,597 +1,628 @@
1
- // lib/rotate.js — stream rotation / failover generator (#253).
2
- // Pure wiring helper: dependencies are injected so unit tests can supply mocks.
3
- import { nowMono } from './clock.js';
4
- import { classifyFailure } from './error-taxonomy.js';
5
- import {
6
- isSwitchableError,
7
- recordFailure,
8
- recordSuccess,
9
- parseRetryAfter,
10
- isKeyPaused,
11
- isKeyRevoked,
12
- isKeyBroken,
13
- formatExhaustionMessage,
14
- sortAttemptList,
15
- getSessionSticky,
16
- setSessionSticky,
17
- clearSessionSticky,
18
- } from './pool.js';
19
- import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
20
- import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
21
- import { createSelectionTrace } from './selection-trace.js';
22
- import { isKeyDraining } from './key-drain.js';
23
- import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
24
- import { nextQuotaReset, poolResetAt } from './quota-window.js';
25
- import {
26
- isModelQuotaAvailable,
27
- hasModelQuotaConfig,
28
- anyModelQuotaAvailable,
29
- formatModelQuotaExhaustion,
30
- } from './model-quota.js';
31
-
32
- import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
33
- import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
34
- import { extractProviderLimits } from './provider-limits-detector.js';
35
- import { recordPromptCache } from './prompt-cache.js';
36
- import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
37
-
38
- export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
39
-
40
- export { preserveDispatchContext } from './stream-metrics.js';
41
-
42
- /**
43
- * Create the rotate(options, pool) async generator used by llm/stream.
44
- * @param {object} deps
45
- */
46
- async function* executeCascade(rotateFn, options, pool, runtime, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, breaker) {
47
- if (currentDepth >= maxDepth || !Array.isArray(runtime.cascade) || runtime.cascade.length === 0) return false;
48
- const fb = pickCascadeFallback(options.provider, runtime, runtime.providerToPool, options.model, runtime.modelPoolByProvider, breaker);
49
- if (!fb || !fb.pool || fb.pool === pool) return false;
50
- pool.state.lastReason = 'CASCADE';
51
- pool.state.lastSwitchAt = now();
52
- pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
53
- pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
54
- if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: cascade to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
55
- const inner = rotateFn({
56
- ...options, provider: fb.provider, model: fb.model || options.model,
57
- __isCascade: true, __cascadeDepth: currentDepth + 1, __requestBudget: budget, __selectionTrace: trace,
58
- }, fb.pool);
59
- for await (const chunk of inner) yield chunk;
60
- return true;
61
- }
62
-
63
- export function createRotate(deps) {
64
- const {
65
- ctx,
66
- dispatchStorage,
67
- buildRuntime,
68
- pushEvent,
69
- notifySwitch,
70
- notifyExhaustion,
71
- notifyEarlyWarning,
72
- recordLatency,
73
- latencyHistogram,
74
- concurrencyTracker,
75
- MARKER,
76
- finishError,
77
- setRotateStartMs,
78
- quotaStore,
79
- circuitBreaker,
80
- logger,
81
- schedulePersist,
82
- trafficSmoother,
83
- keyDrainManager,
84
- selectionTraceStore,
85
- now = nowMono,
86
- } = deps;
87
-
88
- const effectiveNotifyEarlyWarning = notifyEarlyWarning ?? defaultNotifyEarlyWarning;
89
- const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
90
-
91
- function rotate(options, pool) {
92
- return (async function* () {
93
- const runtime0 = buildRuntime();
94
- // #464: Per-request budget (deadline and max attempts) shared across rotation and cascade
95
- const existingBudget = options.__requestBudget;
96
- const budget = existingBudget || createRequestBudget({
97
- maxDurationMs: options.maxDurationMs ?? pool.maxDurationMs ?? runtime0.maxDurationMs ?? 0,
98
- maxAttempts: options.maxAttempts ?? pool.maxAttempts ?? runtime0.maxAttempts ?? 0,
99
- signal: options.signal,
100
- });
101
- const isBudgetOwner = !existingBudget;
102
-
103
- // #461: Track key selection and failover explanation
104
- const trace = options.__selectionTrace || createSelectionTrace(options.requestId, {
105
- provider: options.provider,
106
- model: options.model,
107
- }, selectionTraceStore);
108
- const isTraceOwner = !options.__selectionTrace;
109
- const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
110
- let lastFailure = null;
111
- const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
112
- let activePermit = null;
113
- const sessionId = options?.sessionId ?? options?.session ?? options?.metadata?.sessionId ?? options?.chatId ?? options?.conversationId;
114
- const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now(), sessionId, model: options.model };
115
- setRotateStartMs(reqStore.startMs);
116
- const wallNow = Date.now();
117
- const quotaManaged = hasModelQuotaConfig(pool);
118
- let attemptList = (pool.weightedRefs ?? pool.refs).slice();
119
- attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow) && !isKeyDraining(r, keyDrainManager));
120
- // Issue #500: filter candidate keys by confirmed model capabilities
121
- if (options.model && pool.state.modelCapabilities) {
122
- attemptList = attemptList.filter((ref) => {
123
- const caps = pool.state.modelCapabilities.get(ref);
124
- return !caps || caps.size === 0 || caps.has(options.model);
125
- });
126
- }
127
- if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
128
- const pIdx = attemptList.indexOf(pool.pinnedRef);
129
- if (pIdx > 0) {
130
- attemptList.splice(pIdx, 1);
131
- attemptList.unshift(pool.pinnedRef);
132
- }
133
- }
134
- if (quotaManaged) {
135
- attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
136
- if (attemptList.length === 0) {
137
- pool.state.lastExhaustionAt = now();
138
- pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
139
- logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget exhausted for all ${pool.refs.length} key(s) — no upstream request sent`);
140
- const runtimeQ = buildRuntime();
141
- notifyExhaustion(runtimeQ, pool, { provider: options.provider });
142
- const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
143
- const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
144
- const cascaded = yield* executeCascade(rotate, options, pool, runtimeQ, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn });
145
- if (cascaded) return;
146
- yield finishError(LOCAL_QUOTA_CODE, formatModelQuotaExhaustion(options.provider, pool));
147
- return;
148
- }
149
- }
150
- const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
151
- if (strategy !== 'round-robin') {
152
- attemptList = sortAttemptList(attemptList, strategy, {
153
- latencyHistogram,
154
- concurrencyTracker,
155
- weights: pool.weightsMap,
156
- tiers: pool.tiers,
157
- sessionId,
158
- sessionSticky: sessionId ? getSessionSticky(pool, sessionId) : null,
159
- lastUsed: pool.state.lastUsed,
160
- });
161
- }
162
- if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
163
- // #193 / #365: prefer least-loaded key within limit with weights
164
- const available = attemptList.filter((r) => {
165
- if (isKeyPaused(pool, r)) return false;
166
- const fu = pool.state.failedUntil.get(r) ?? 0;
167
- if (fu > now()) return false;
168
- const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
169
- if (exp !== undefined && now() >= exp) return false;
170
- return true;
171
- });
172
- const preferred = concurrencyTracker.pickLeastLoaded(available, now(), pool.weightsMap);
173
- if (preferred && attemptList[0] !== preferred) {
174
- const list = attemptList.slice();
175
- const i = list.indexOf(preferred);
176
- if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
177
- attemptList = list;
178
- }
179
- }
180
-
181
- const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
182
- if (!targetRef) return;
183
- if (sessionId) clearSessionSticky(pool, sessionId);
184
- if (errCode === LOCAL_QUOTA_CODE) return;
185
- let headerResetMs = null;
186
- if (rawHeaders) {
187
- const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
188
- ? rawHeaders
189
- : extractProviderLimits(rawHeaders);
190
- headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
191
- }
192
- if (!headerResetMs) {
193
- const pRetry = parseRetryAfter(errMsg);
194
- if (pRetry) headerResetMs = pRetry;
195
- }
196
-
197
- const _base = pool.cooldownMs ?? cooldownMs;
198
- const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
199
- let _b = 0;
200
- const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
201
-
202
- if (headerResetMs && headerResetMs > 0) {
203
- const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
204
- pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
205
- if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
206
- pool.state.resetReasons.set(targetRef, 'header');
207
- if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
208
- pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
209
- _b = dynamicCooldownMs;
210
- pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
211
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
212
-
213
- const recTimer = setTimeout(() => {
214
- if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
215
- pool.state.failedUntil.delete(targetRef);
216
- pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
217
- }
218
- }, dynamicCooldownMs + 50);
219
- if (typeof recTimer.unref === 'function') recTimer.unref();
220
- } else {
221
- const _effBase = _base;
222
- _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
223
- if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
224
- pool.state.resetReasons.set(targetRef, 'backoff');
225
- }
226
-
227
- // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
228
- // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
229
- // must NOT be locked until midnight. Keep those on ordinary cooldowns.
230
- const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
231
- (/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
232
-
233
- const isQuota = !isRateThrottling && (
234
- (errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
235
- /\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
236
- );
237
- const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
238
- if (isQuota && qWindow) {
239
- const quotaResetAt = poolResetAt(pool, qWindow, now());
240
- if (quotaResetAt && quotaResetAt > now()) {
241
- if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
242
- pool.state.quotaWindows.set(targetRef, quotaResetAt);
243
- pool.state.failedUntil.set(targetRef, quotaResetAt);
244
- }
245
- }
246
-
247
- activePermit?.failure();
248
- trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
249
- pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
250
- if (typeof schedulePersist === 'function') {
251
- try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
252
- }
253
- if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
254
- const total = pool.refs?.length || 1;
255
- let available = 0;
256
- const at = now();
257
- for (const r of (pool.refs || [])) {
258
- if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
259
- available++;
260
- }
261
- }
262
- const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
263
- if (total > 0 && (available / total) <= threshold) {
264
- effectiveNotifyEarlyWarning(runtime0, pool, {
265
- provider: options.provider,
266
- availableCount: available,
267
- totalCount: total,
268
- });
269
- }
270
- }
271
- if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
272
- if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
273
- const _cStr = String(errCode ?? '').toUpperCase();
274
- const _isAuth = cls.reason === 'auth' || cls.code === '401' || cls.code === '403' ||
275
- _cStr === 'AUTH' || _cStr === '401' || _cStr === '403' ||
276
- _cStr === 'PERMISSION_DENIED' || _cStr === 'UNAUTHENTICATED' ||
277
- /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated|permission[_\s-]?denied/i.test(errMsg);
278
-
279
- if (_isAuth) {
280
- const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
281
- pool.state.authFailCounts.set(targetRef, _c2);
282
- if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
283
- pool.state.revokedRefs.add(targetRef);
284
- if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
285
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(targetRef);
286
- else if (Array.isArray(pool.state.pausedRefs) && !pool.state.pausedRefs.includes(targetRef)) pool.state.pausedRefs.push(targetRef);
287
- if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
288
- pool.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
289
- pool.state.failedUntil.set(targetRef, Infinity);
290
- pushEvent(pool, targetRef, 'KEY_AUTH_PAUSED', 0);
291
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently PAUSED on auth failure (${errCode})`);
292
- // Fan-out permanent pause across all pools sharing this physical ref (#408):
293
- const rAll = buildRuntime();
294
- const candidatePools = [
295
- ...(Array.isArray(rAll.pools) ? rAll.pools : []),
296
- ...(rAll.index?.allPools ? rAll.index.allPools.values() : []),
297
- ...(rAll.providerToPool ? rAll.providerToPool.values() : []),
298
- ];
299
- if (rAll.modelPoolByProvider) {
300
- for (const map of rAll.modelPoolByProvider.values()) { if (map) candidatePools.push(...map.values()); }
301
- }
302
- const seenPools = new Set();
303
- for (const other of candidatePools) {
304
- if (!other || seenPools.has(other)) continue;
305
- seenPools.add(other);
306
- if (other === pool) continue;
307
- const hasRef = (Array.isArray(other.refs) && other.refs.includes(targetRef)) ||
308
- (other.weightsMap instanceof Map && other.weightsMap.has(targetRef));
309
- if (hasRef && other.state) {
310
- if (!other.state.revokedRefs) other.state.revokedRefs = new Set();
311
- other.state.revokedRefs.add(targetRef);
312
- if (!other.state.pausedRefs) other.state.pausedRefs = new Set();
313
- if (other.state.pausedRefs instanceof Set) other.state.pausedRefs.add(targetRef);
314
- else if (Array.isArray(other.state.pausedRefs) && !other.state.pausedRefs.includes(targetRef)) other.state.pausedRefs.push(targetRef);
315
- if (!other.state.pauseReasons) other.state.pauseReasons = new Map();
316
- other.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
317
- if (other.state.failedUntil) other.state.failedUntil.set(targetRef, Infinity);
318
- pushEvent(other, targetRef, 'KEY_AUTH_PAUSED', 0);
319
- }
320
- }
321
- } else {
322
- pool.state.authFailCounts.delete(targetRef);
323
- // Soft/transient errors (TIMEOUT, 5xx, socket) do not count toward quarantine (#473).
324
- // recordFailure already maintains pool.state.failCounts accurately for non-soft failures.
325
- if (!isQuota && !cls.soft) {
326
- const _fc = pool.state.failCounts.get(targetRef) ?? 0;
327
- if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
328
- const quarantineMs = 1800000;
329
- pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
330
- pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
331
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
332
- }
333
- }
334
- }
335
- };
336
-
337
- for (let attempt = 0; attempt < attemptList.length; attempt++) {
338
- const candidateRef = attemptList[attempt];
339
- // #496: Traffic smoothing micro-queue
340
- const smoother = pool.trafficSmoother ?? runtime0.trafficSmoother ?? trafficSmoother;
341
- if (smoother && candidateRef) {
342
- const smoothRes = await smoother.acquire(candidateRef, {
343
- rpmLimit: pool.rpmLimit,
344
- burstLimit: pool.trafficSmoothingBurst,
345
- maxQueueWaitMs: pool.maxQueueWaitMs,
346
- signal: options.signal,
347
- });
348
- if (smoothRes.overflow) {
349
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${candidateRef} micro-queue burst overflow (${smoothRes.delayMs}ms) — rotating to next candidate`);
350
- continue;
351
- }
352
- if (smoothRes.delayed) {
353
- pool.state.smoothedRequests = (pool.state.smoothedRequests ?? 0) + 1;
354
- pushEvent(pool, candidateRef, 'TRAFFIC_SMOOTHED', smoothRes.delayMs);
355
- }
356
- }
357
- // #464: Check deadline & attempt budget before dispatch
358
- if (!budget.canAttempt()) {
359
- const isTimeout = budget.isExpired();
360
- const code = isTimeout ? BUDGET_EXHAUSTED_TIMEOUT : BUDGET_EXHAUSTED_ATTEMPTS;
361
- const msg = isTimeout
362
- ? `[dsh-key-rotation] request deadline exceeded (${budget.maxDurationMs}ms)`
363
- : `[dsh-key-rotation] request attempt budget exceeded (${budget.maxAttempts} attempts)`;
364
- logWarn(msg);
365
- yield finishError(code, msg);
366
- if (isBudgetOwner) budget.dispose();
367
- return;
368
- }
369
-
370
- activePermit = breaker ? breaker.acquire(options.provider) : null;
371
- if (breaker && !activePermit) {
372
- logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
373
- const runtimeQ = buildRuntime();
374
- const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
375
- const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
376
- const cascaded = yield* executeCascade(rotate, options, pool, runtimeQ, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, breaker);
377
- if (cascaded) return;
378
- yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
379
- return;
380
- }
381
- try {
382
- let yielded = false;
383
- let switching = false;
384
- let inner;
385
- try {
386
- // #464 & #461: Record attempt & trace dispatch
387
- budget.recordAttempt();
388
- const curCandidate = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
389
- trace.addStep(curCandidate, 'DISPATCHED', 'ATTEMPT', `Attempt ${budget.getAttempts()}`);
390
-
391
- // mark the internal dispatch so the interceptor does not re-rotate
392
- inner = dispatchStorage.run(reqStore, () => {
393
- const llm = ctx.get('llm');
394
- if (!llm || typeof llm.stream !== 'function') {
395
- throw new Error('dsh-key-rotation: llm service unavailable');
396
- }
397
- return llm.stream({ ...options, [MARKER]: true, signal: budget.signal });
398
- });
399
- } catch (e) {
400
- if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
401
- const code = e?.code ?? (e?.localQuota ? LOCAL_QUOTA_CODE : LOCAL_POOL_EXHAUSTED_CODE);
402
- lastFailure = finishError(code, String(e?.message ?? ''));
403
- logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local rejection (${code}) — no upstream request sent`);
404
- break;
405
- }
406
- const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
407
- penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(curRef));
408
- lastFailure = finishError(e?.code ?? 'TRANSPORT',
409
- `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
410
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
411
- continue;
412
- }
413
-
414
- const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
415
- try {
416
- for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
417
- if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
418
- const fallbackRef = reqStore.pickedRef ?? _pickedRef;
419
- if (fallbackRef) {
420
- const ok = concurrencyTracker.acquire(fallbackRef, now(), pool.concurrencyLimit ?? runtime0.concurrencyLimit ?? 0);
421
- if (ok) reqStore.concurrencyRef = fallbackRef;
422
- }
423
- }
424
- // Only actual content deltas lock the stream (no more rotation).
425
- // Structural/metadata chunks (block-start/end, usage) do not.
426
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
427
- yielded = true;
428
- yield chunk;
429
- continue;
430
- }
431
- if (chunk && chunk.type === 'finish') {
432
- const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers ?? chunk?.reason?.failure?.headers;
433
- const kind = chunk.reason?.kind;
434
- const failure = chunk.reason?.failure;
435
- const code = failure?.code;
436
- const message = failure?.message ?? '';
437
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
438
- const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
439
- const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
440
- if (switchable) {
441
- penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
442
- recordFailureCategory(pool, code ?? 'UNKNOWN', message, activeRef, now());
443
- pool.state.switches = (pool.state.switches ?? 0) + 1;
444
- pool.state.lastReason = String(code ?? 'UNKNOWN');
445
- pool.state.lastSwitchAt = now();
446
- lastFailure = chunk;
447
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
448
- // #216: per-switch webhook (opt-in switchNotify), deduped per provider
449
- if (switchNotify && activeRef) {
450
- notifySwitch(runtime0, pool, {
451
- provider: options.provider,
452
- from: activeRef,
453
- code: String(code ?? 'UNKNOWN'),
454
- at: pool.state.lastSwitchAt,
455
- });
456
- }
457
- switching = true;
458
- break;
459
- }
460
- // Error/abort terminals are not successful responses. After a
461
- // content delta we may penalize, but must never replay the request.
462
- if (kind === 'error' || kind === 'aborted') {
463
- if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
464
- penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
465
- }
466
- yield chunk;
467
- return;
468
- }
469
- // cost tracking if provider returns usage.cost
470
- const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
471
- if (chunk.usage?.cost != null && activeRef) {
472
- const c = Number(chunk.usage.cost);
473
- if (!isNaN(c)) {
474
- if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
475
- pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
476
- // #208: cost per day per key (mirrors usageDays) for budget checks
477
- if (!pool.state.costDays) pool.state.costDays = new Map();
478
- const cMap = pool.state.costDays.get(activeRef) || new Map();
479
- cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
480
- pool.state.costDays.set(activeRef, cMap);
481
- }
482
- }
483
- // Usage by day (#119)
484
- if (activeRef) {
485
- if (!pool.state.usageDays) pool.state.usageDays = new Map();
486
- const dayMap = pool.state.usageDays.get(activeRef) || new Map();
487
- dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
488
- pool.state.usageDays.set(activeRef, dayMap);
489
- }
490
- // Per-model request detail (#121)
491
- if (activeRef && options.model) {
492
- if (!pool.state.byModel) pool.state.byModel = new Map();
493
- let byRef = pool.state.byModel.get(activeRef);
494
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
495
- byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
496
- }
497
- // #512: Record success metrics, tokens, local quota, limits and proactive pause
498
- if (activeRef) {
499
- recordStreamSuccessMetrics(pool, activeRef, options, chunk, {
500
- now,
501
- sessionId,
502
- attempt,
503
- runtime0,
504
- quotaManaged,
505
- schedulePersist,
506
- logWarn,
507
- rawHdrs,
508
- pushEvent,
509
- quotaStore,
510
- });
511
- }
512
- recordLatency(pool, reqStore);
513
- activePermit?.success();
514
- if (isTraceOwner) trace.finish('SUCCESS', activeRef);
515
- yield chunk;
516
- return;
517
- }
518
- yield chunk;
519
- }
520
- } catch (e) {
521
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
522
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
523
- if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
524
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
525
- recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
526
- pool.state.switches = (pool.state.switches ?? 0) + 1;
527
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
528
- pool.state.lastSwitchAt = now();
529
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
530
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
531
- if (switchNotify && activeRef) {
532
- notifySwitch(runtime0, pool, {
533
- provider: options.provider,
534
- from: activeRef,
535
- code: String(e?.code ?? 'TRANSPORT'),
536
- at: pool.state.lastSwitchAt,
537
- });
538
- }
539
- continue; // Failover to next key!
540
- }
541
- if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
542
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
543
- }
544
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
545
- return;
546
- }
547
-
548
- if (switching) continue; // try the next key
549
- if (yielded) {
550
- const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
551
- if (activeRef) recordSuccess(pool, activeRef, now());
552
- recordLatency(pool, reqStore);
553
- activePermit?.success();
554
- if (isTraceOwner) trace.finish('SUCCESS', activeRef);
555
- } else if (isTraceOwner) {
556
- trace.finish('COMPLETED', reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed);
557
- }
558
- return; // clean end — served
559
- } finally {
560
- if (reqStore.concurrencyRef) {
561
- concurrencyTracker?.release(reqStore.concurrencyRef);
562
- reqStore.concurrencyRef = null;
563
- }
564
- reqStore.pickedRef = undefined;
565
- // Also runs on consumer return(), abort, empty EOF and local errors.
566
- activePermit?.release();
567
- }
568
- }
569
-
570
- if (isBudgetOwner) {
571
- budget.dispose();
572
- }
573
- if (isTraceOwner) {
574
- trace.finish('TERMINATED', reqStore.pickedRef ?? pool.state.lastUsed);
575
- }
576
-
577
- // pool exhausted — all keys cooling or missing
578
- pool.state.lastExhaustionAt = now();
579
- pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
580
- logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
581
- const runtime = buildRuntime();
582
- // notify via extracted helper (see notifyExhaustion above)
583
- notifyExhaustion(runtime, pool, { provider: options.provider });
584
-
585
- // #194 / #364: cross-provider cascade failover with model mapping
586
- const maxDepth = runtime.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
587
- const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
588
- const cascaded = yield* executeCascade(rotate, options, pool, runtime, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, circuitBreaker);
589
- if (cascaded) return;
590
-
591
- const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
592
- yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
593
- })();
594
- }
595
-
596
- return rotate;
597
- }
1
+ // lib/rotate.js — stream rotation / failover generator (#253).
2
+ // Pure wiring helper: dependencies are injected so unit tests can supply mocks.
3
+ import { nowMono } from './clock.js';
4
+ import { classifyFailure } from './error-taxonomy.js';
5
+ import {
6
+ isSwitchableError,
7
+ recordFailure,
8
+ recordSuccess,
9
+ parseRetryAfter,
10
+ isKeyPaused,
11
+ isKeyRevoked,
12
+ isKeyBroken,
13
+ formatExhaustionMessage,
14
+ sortAttemptList,
15
+ getSessionSticky,
16
+ setSessionSticky,
17
+ clearSessionSticky,
18
+ } from './pool.js';
19
+ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
20
+ import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
21
+ import { createSelectionTrace } from './selection-trace.js';
22
+ import { isKeyDraining } from './key-drain.js';
23
+ import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
24
+ import { nextQuotaReset, poolResetAt } from './quota-window.js';
25
+ import {
26
+ isModelQuotaAvailable,
27
+ hasModelQuotaConfig,
28
+ anyModelQuotaAvailable,
29
+ formatModelQuotaExhaustion,
30
+ } from './model-quota.js';
31
+
32
+ import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
33
+ import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
34
+ import { extractProviderLimits } from './provider-limits-detector.js';
35
+ import { recordPromptCache } from './prompt-cache.js';
36
+ import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
37
+
38
+ export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
39
+
40
+ export { preserveDispatchContext } from './stream-metrics.js';
41
+
42
+ /**
43
+ * Create the rotate(options, pool) async generator used by llm/stream.
44
+ * @param {object} deps
45
+ */
46
+ async function* executeCascade(rotateFn, options, pool, runtime, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, breaker) {
47
+ if (currentDepth >= maxDepth || !Array.isArray(runtime.cascade) || runtime.cascade.length === 0) return false;
48
+ const fb = pickCascadeFallback(options.provider, runtime, runtime.providerToPool, options.model, runtime.modelPoolByProvider, breaker);
49
+ if (!fb || !fb.pool || fb.pool === pool) return false;
50
+ pool.state.lastReason = 'CASCADE';
51
+ pool.state.lastSwitchAt = now();
52
+ pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
53
+ pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
54
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: cascade to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
55
+ const inner = rotateFn({
56
+ ...options, provider: fb.provider, model: fb.model || options.model,
57
+ __isCascade: true, __cascadeDepth: currentDepth + 1, __requestBudget: budget, __selectionTrace: trace,
58
+ }, fb.pool);
59
+ for await (const chunk of inner) yield chunk;
60
+ return true;
61
+ }
62
+
63
+ export function createRotate(deps) {
64
+ const {
65
+ ctx,
66
+ dispatchStorage,
67
+ buildRuntime,
68
+ pushEvent,
69
+ notifySwitch,
70
+ notifyExhaustion,
71
+ notifyEarlyWarning,
72
+ recordLatency,
73
+ latencyHistogram,
74
+ concurrencyTracker,
75
+ MARKER,
76
+ finishError,
77
+ setRotateStartMs,
78
+ quotaStore,
79
+ circuitBreaker,
80
+ logger,
81
+ schedulePersist,
82
+ trafficSmoother,
83
+ keyDrainManager,
84
+ selectionTraceStore,
85
+ now = nowMono,
86
+ } = deps;
87
+
88
+ const effectiveNotifyEarlyWarning = notifyEarlyWarning ?? defaultNotifyEarlyWarning;
89
+ const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
90
+
91
+ function rotate(options, pool) {
92
+ return (async function* () {
93
+ const runtime0 = buildRuntime();
94
+ // #464: Per-request budget (deadline and max attempts) shared across rotation and cascade
95
+ const existingBudget = options.__requestBudget;
96
+ const budget = existingBudget || createRequestBudget({
97
+ maxDurationMs: options.maxDurationMs ?? pool.maxDurationMs ?? runtime0.maxDurationMs ?? 0,
98
+ maxAttempts: options.maxAttempts ?? pool.maxAttempts ?? runtime0.maxAttempts ?? 0,
99
+ signal: options.signal,
100
+ });
101
+ const isBudgetOwner = !existingBudget;
102
+
103
+ try {
104
+ // #461: Track key selection and failover explanation
105
+ const trace = options.__selectionTrace || createSelectionTrace(options.requestId, {
106
+ provider: options.provider,
107
+ model: options.model,
108
+ }, selectionTraceStore);
109
+ const isTraceOwner = !options.__selectionTrace;
110
+ const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
111
+ let lastFailure = null;
112
+ const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
113
+ let activePermit = null;
114
+ const sessionId = options?.sessionId ?? options?.session ?? options?.metadata?.sessionId ?? options?.chatId ?? options?.conversationId;
115
+ const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now(), sessionId, model: options.model };
116
+ setRotateStartMs(reqStore.startMs);
117
+ const wallNow = Date.now();
118
+ const quotaManaged = hasModelQuotaConfig(pool);
119
+
120
+ // #461: Record skipped candidates in trace before filtering
121
+ const evaluatedRefs = new Set();
122
+ for (const r of (pool.refs ?? [])) {
123
+ if (evaluatedRefs.has(r)) continue;
124
+ evaluatedRefs.add(r);
125
+ if (isKeyPaused(pool, r)) {
126
+ trace.addStep(r, 'SKIPPED', 'PAUSED', 'Key is marked paused');
127
+ } else if (isKeyRevoked(pool, r)) {
128
+ trace.addStep(r, 'SKIPPED', 'REVOKED', 'Key is revoked');
129
+ } else if (isKeyBroken(pool, r, wallNow)) {
130
+ trace.addStep(r, 'SKIPPED', 'BROKEN', 'Key is in broken quarantine');
131
+ } else if (isKeyDraining(r, keyDrainManager)) {
132
+ trace.addStep(r, 'SKIPPED', 'DRAINING', 'Key is draining active requests');
133
+ } else {
134
+ const fu = pool.state?.failedUntil?.get(r) ?? 0;
135
+ if (fu > wallNow) {
136
+ trace.addStep(r, 'SKIPPED', 'COOLDOWN', `In cooldown for ${Math.round((fu - wallNow) / 1000)}s`);
137
+ } else if (options.model && pool.state.modelCapabilities) {
138
+ const caps = pool.state.modelCapabilities.get(r);
139
+ if (caps && caps.size > 0 && !caps.has(options.model)) {
140
+ trace.addStep(r, 'SKIPPED', 'CAPABILITY_MISMATCH', `Missing capability for model ${options.model}`);
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ let attemptList = (pool.weightedRefs ?? pool.refs).slice();
147
+ attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow) && !isKeyDraining(r, keyDrainManager));
148
+ // Issue #500: filter candidate keys by confirmed model capabilities
149
+ if (options.model && pool.state.modelCapabilities) {
150
+ attemptList = attemptList.filter((ref) => {
151
+ const caps = pool.state.modelCapabilities.get(ref);
152
+ return !caps || caps.size === 0 || caps.has(options.model);
153
+ });
154
+ }
155
+ if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
156
+ const pIdx = attemptList.indexOf(pool.pinnedRef);
157
+ if (pIdx > 0) {
158
+ attemptList.splice(pIdx, 1);
159
+ attemptList.unshift(pool.pinnedRef);
160
+ trace.addStep(pool.pinnedRef, 'PRIORITIZED', 'PINNED', 'Key is pinned');
161
+ }
162
+ }
163
+ if (quotaManaged) {
164
+ attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
165
+ if (attemptList.length === 0) {
166
+ pool.state.lastExhaustionAt = now();
167
+ pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
168
+ logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget exhausted for all ${pool.refs.length} key(s) — no upstream request sent`);
169
+ const runtimeQ = buildRuntime();
170
+ notifyExhaustion(runtimeQ, pool, { provider: options.provider });
171
+ const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
172
+ const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
173
+ const cascaded = yield* executeCascade(rotate, options, pool, runtimeQ, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn });
174
+ if (cascaded) return;
175
+ yield finishError(LOCAL_QUOTA_CODE, formatModelQuotaExhaustion(options.provider, pool));
176
+ return;
177
+ }
178
+ }
179
+ const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
180
+ if (strategy !== 'round-robin') {
181
+ attemptList = sortAttemptList(attemptList, strategy, {
182
+ latencyHistogram,
183
+ concurrencyTracker,
184
+ weights: pool.weightsMap,
185
+ tiers: pool.tiers,
186
+ sessionId,
187
+ sessionSticky: sessionId ? getSessionSticky(pool, sessionId) : null,
188
+ lastUsed: pool.state.lastUsed,
189
+ });
190
+ }
191
+ if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
192
+ // #193 / #365: prefer least-loaded key within limit with weights
193
+ const available = attemptList.filter((r) => {
194
+ if (isKeyPaused(pool, r)) return false;
195
+ const fu = pool.state.failedUntil.get(r) ?? 0;
196
+ if (fu > now()) return false;
197
+ const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
198
+ if (exp !== undefined && now() >= exp) return false;
199
+ return true;
200
+ });
201
+ const preferred = concurrencyTracker.pickLeastLoaded(available, now(), pool.weightsMap);
202
+ if (preferred && attemptList[0] !== preferred) {
203
+ const list = attemptList.slice();
204
+ const i = list.indexOf(preferred);
205
+ if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
206
+ attemptList = list;
207
+ }
208
+ }
209
+
210
+ const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
211
+ if (!targetRef) return;
212
+ if (sessionId) clearSessionSticky(pool, sessionId);
213
+ if (errCode === LOCAL_QUOTA_CODE) return;
214
+ let headerResetMs = null;
215
+ if (rawHeaders) {
216
+ const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
217
+ ? rawHeaders
218
+ : extractProviderLimits(rawHeaders);
219
+ headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
220
+ }
221
+ if (!headerResetMs) {
222
+ const pRetry = parseRetryAfter(errMsg);
223
+ if (pRetry) headerResetMs = pRetry;
224
+ }
225
+
226
+ const _base = pool.cooldownMs ?? cooldownMs;
227
+ const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
228
+ let _b = 0;
229
+ const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
230
+
231
+ if (headerResetMs && headerResetMs > 0) {
232
+ const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
233
+ pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
234
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
235
+ pool.state.resetReasons.set(targetRef, 'header');
236
+ if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
237
+ pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
238
+ _b = dynamicCooldownMs;
239
+ pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
240
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
241
+
242
+ const recTimer = setTimeout(() => {
243
+ if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
244
+ pool.state.failedUntil.delete(targetRef);
245
+ pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
246
+ }
247
+ }, dynamicCooldownMs + 50);
248
+ if (typeof recTimer.unref === 'function') recTimer.unref();
249
+ } else {
250
+ const _effBase = _base;
251
+ _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
252
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
253
+ pool.state.resetReasons.set(targetRef, 'backoff');
254
+ }
255
+
256
+ // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
257
+ // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
258
+ // must NOT be locked until midnight. Keep those on ordinary cooldowns.
259
+ const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
260
+ (/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
261
+
262
+ const isQuota = !isRateThrottling && (
263
+ (errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
264
+ /\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
265
+ );
266
+ const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
267
+ if (isQuota && qWindow) {
268
+ const quotaResetAt = poolResetAt(pool, qWindow, now());
269
+ if (quotaResetAt && quotaResetAt > now()) {
270
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
271
+ pool.state.quotaWindows.set(targetRef, quotaResetAt);
272
+ pool.state.failedUntil.set(targetRef, quotaResetAt);
273
+ }
274
+ }
275
+
276
+ activePermit?.failure();
277
+ trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
278
+ pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
279
+ if (typeof schedulePersist === 'function') {
280
+ try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
281
+ }
282
+ if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
283
+ const total = pool.refs?.length || 1;
284
+ let available = 0;
285
+ const at = now();
286
+ for (const r of (pool.refs || [])) {
287
+ if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
288
+ available++;
289
+ }
290
+ }
291
+ const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
292
+ if (total > 0 && (available / total) <= threshold) {
293
+ effectiveNotifyEarlyWarning(runtime0, pool, {
294
+ provider: options.provider,
295
+ availableCount: available,
296
+ totalCount: total,
297
+ });
298
+ }
299
+ }
300
+ if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
301
+ if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
302
+ const _cStr = String(errCode ?? '').toUpperCase();
303
+ const _isAuth = cls.reason === 'auth' || cls.code === '401' || cls.code === '403' ||
304
+ _cStr === 'AUTH' || _cStr === '401' || _cStr === '403' ||
305
+ _cStr === 'PERMISSION_DENIED' || _cStr === 'UNAUTHENTICATED' ||
306
+ /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated|permission[_\s-]?denied/i.test(errMsg);
307
+
308
+ if (_isAuth) {
309
+ const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
310
+ pool.state.authFailCounts.set(targetRef, _c2);
311
+ if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
312
+ pool.state.revokedRefs.add(targetRef);
313
+ if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
314
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(targetRef);
315
+ else if (Array.isArray(pool.state.pausedRefs) && !pool.state.pausedRefs.includes(targetRef)) pool.state.pausedRefs.push(targetRef);
316
+ if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
317
+ pool.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
318
+ pool.state.failedUntil.set(targetRef, Infinity);
319
+ pushEvent(pool, targetRef, 'KEY_AUTH_PAUSED', 0);
320
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently PAUSED on auth failure (${errCode})`);
321
+ // Fan-out permanent pause across all pools sharing this physical ref (#408):
322
+ const rAll = buildRuntime();
323
+ const candidatePools = [
324
+ ...(Array.isArray(rAll.pools) ? rAll.pools : []),
325
+ ...(rAll.index?.allPools ? rAll.index.allPools.values() : []),
326
+ ...(rAll.providerToPool ? rAll.providerToPool.values() : []),
327
+ ];
328
+ if (rAll.modelPoolByProvider) {
329
+ for (const map of rAll.modelPoolByProvider.values()) { if (map) candidatePools.push(...map.values()); }
330
+ }
331
+ const seenPools = new Set();
332
+ for (const other of candidatePools) {
333
+ if (!other || seenPools.has(other)) continue;
334
+ seenPools.add(other);
335
+ if (other === pool) continue;
336
+ const hasRef = (Array.isArray(other.refs) && other.refs.includes(targetRef)) ||
337
+ (other.weightsMap instanceof Map && other.weightsMap.has(targetRef));
338
+ if (hasRef && other.state) {
339
+ if (!other.state.revokedRefs) other.state.revokedRefs = new Set();
340
+ other.state.revokedRefs.add(targetRef);
341
+ if (!other.state.pausedRefs) other.state.pausedRefs = new Set();
342
+ if (other.state.pausedRefs instanceof Set) other.state.pausedRefs.add(targetRef);
343
+ else if (Array.isArray(other.state.pausedRefs) && !other.state.pausedRefs.includes(targetRef)) other.state.pausedRefs.push(targetRef);
344
+ if (!other.state.pauseReasons) other.state.pauseReasons = new Map();
345
+ other.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
346
+ if (other.state.failedUntil) other.state.failedUntil.set(targetRef, Infinity);
347
+ pushEvent(other, targetRef, 'KEY_AUTH_PAUSED', 0);
348
+ }
349
+ }
350
+ } else {
351
+ pool.state.authFailCounts.delete(targetRef);
352
+ // Soft/transient errors (TIMEOUT, 5xx, socket) do not count toward quarantine (#473).
353
+ // recordFailure already maintains pool.state.failCounts accurately for non-soft failures.
354
+ if (!isQuota && !cls.soft) {
355
+ const _fc = pool.state.failCounts.get(targetRef) ?? 0;
356
+ if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
357
+ const quarantineMs = 1800000;
358
+ pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
359
+ pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
360
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
361
+ }
362
+ }
363
+ }
364
+ };
365
+
366
+ for (let attempt = 0; attempt < attemptList.length; attempt++) {
367
+ const candidateRef = attemptList[attempt];
368
+ // #496: Traffic smoothing micro-queue
369
+ const smoother = pool.trafficSmoother ?? runtime0.trafficSmoother ?? trafficSmoother;
370
+ if (smoother && candidateRef) {
371
+ const smoothRes = await smoother.acquire(candidateRef, {
372
+ rpmLimit: pool.rpmLimit,
373
+ burstLimit: pool.trafficSmoothingBurst,
374
+ maxQueueWaitMs: pool.maxQueueWaitMs,
375
+ signal: budget.signal,
376
+ });
377
+ if (smoothRes.overflow) {
378
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${candidateRef} micro-queue burst overflow (${smoothRes.delayMs}ms) — rotating to next candidate`);
379
+ continue;
380
+ }
381
+ if (smoothRes.delayed) {
382
+ pool.state.smoothedRequests = (pool.state.smoothedRequests ?? 0) + 1;
383
+ pushEvent(pool, candidateRef, 'TRAFFIC_SMOOTHED', smoothRes.delayMs);
384
+ }
385
+ }
386
+ // #464: Check deadline & attempt budget before dispatch
387
+ if (!budget.canAttempt()) {
388
+ const isTimeout = budget.isExpired();
389
+ const code = isTimeout ? BUDGET_EXHAUSTED_TIMEOUT : BUDGET_EXHAUSTED_ATTEMPTS;
390
+ const msg = isTimeout
391
+ ? `[dsh-key-rotation] request deadline exceeded (${budget.maxDurationMs}ms)`
392
+ : `[dsh-key-rotation] request attempt budget exceeded (${budget.maxAttempts} attempts)`;
393
+ logWarn(msg);
394
+ yield finishError(code, msg);
395
+ if (isBudgetOwner) budget.dispose();
396
+ return;
397
+ }
398
+
399
+ activePermit = breaker ? breaker.acquire(options.provider) : null;
400
+ if (breaker && !activePermit) {
401
+ logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
402
+ const runtimeQ = buildRuntime();
403
+ const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
404
+ const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
405
+ const cascaded = yield* executeCascade(rotate, options, pool, runtimeQ, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, breaker);
406
+ if (cascaded) return;
407
+ yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
408
+ return;
409
+ }
410
+ try {
411
+ let yielded = false;
412
+ let switching = false;
413
+ let inner;
414
+ try {
415
+ // #464 & #461: Record attempt & trace dispatch
416
+ budget.recordAttempt();
417
+ const curCandidate = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
418
+ trace.addStep(curCandidate, 'DISPATCHED', 'ATTEMPT', `Attempt ${budget.getAttempts()}`);
419
+
420
+ // mark the internal dispatch so the interceptor does not re-rotate
421
+ inner = dispatchStorage.run(reqStore, () => {
422
+ const llm = ctx.get('llm');
423
+ if (!llm || typeof llm.stream !== 'function') {
424
+ throw new Error('dsh-key-rotation: llm service unavailable');
425
+ }
426
+ return llm.stream({ ...options, [MARKER]: true, signal: budget.signal });
427
+ });
428
+ } catch (e) {
429
+ if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
430
+ const code = e?.code ?? (e?.localQuota ? LOCAL_QUOTA_CODE : LOCAL_POOL_EXHAUSTED_CODE);
431
+ lastFailure = finishError(code, String(e?.message ?? ''));
432
+ logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local rejection (${code}) — no upstream request sent`);
433
+ break;
434
+ }
435
+ const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
436
+ penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(curRef));
437
+ lastFailure = finishError(e?.code ?? 'TRANSPORT',
438
+ `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
439
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
440
+ continue;
441
+ }
442
+
443
+ const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
444
+ try {
445
+ for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
446
+ if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
447
+ const fallbackRef = reqStore.pickedRef ?? _pickedRef;
448
+ if (fallbackRef) {
449
+ const ok = concurrencyTracker.acquire(fallbackRef, now(), pool.concurrencyLimit ?? runtime0.concurrencyLimit ?? 0);
450
+ if (ok) reqStore.concurrencyRef = fallbackRef;
451
+ }
452
+ }
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;
457
+ yield chunk;
458
+ continue;
459
+ }
460
+ if (chunk && chunk.type === 'finish') {
461
+ const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers ?? chunk?.reason?.failure?.headers;
462
+ const kind = chunk.reason?.kind;
463
+ const failure = chunk.reason?.failure;
464
+ const code = failure?.code;
465
+ const message = failure?.message ?? '';
466
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
467
+ const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
468
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
469
+ if (switchable) {
470
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
471
+ recordFailureCategory(pool, code ?? 'UNKNOWN', message, activeRef, now());
472
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
473
+ pool.state.lastReason = String(code ?? 'UNKNOWN');
474
+ pool.state.lastSwitchAt = now();
475
+ lastFailure = chunk;
476
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
477
+ // #216: per-switch webhook (opt-in switchNotify), deduped per provider
478
+ if (switchNotify && activeRef) {
479
+ notifySwitch(runtime0, pool, {
480
+ provider: options.provider,
481
+ from: activeRef,
482
+ code: String(code ?? 'UNKNOWN'),
483
+ at: pool.state.lastSwitchAt,
484
+ });
485
+ }
486
+ switching = true;
487
+ break;
488
+ }
489
+ // Error/abort terminals are not successful responses. After a
490
+ // content delta we may penalize, but must never replay the request.
491
+ if (kind === 'error' || kind === 'aborted') {
492
+ if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
493
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
494
+ }
495
+ yield chunk;
496
+ return;
497
+ }
498
+ // cost tracking if provider returns usage.cost
499
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
500
+ if (chunk.usage?.cost != null && activeRef) {
501
+ const c = Number(chunk.usage.cost);
502
+ if (!isNaN(c)) {
503
+ if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
504
+ pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
505
+ // #208: cost per day per key (mirrors usageDays) for budget checks
506
+ if (!pool.state.costDays) pool.state.costDays = new Map();
507
+ const cMap = pool.state.costDays.get(activeRef) || new Map();
508
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
509
+ pool.state.costDays.set(activeRef, cMap);
510
+ }
511
+ }
512
+ // Usage by day (#119)
513
+ if (activeRef) {
514
+ if (!pool.state.usageDays) pool.state.usageDays = new Map();
515
+ const dayMap = pool.state.usageDays.get(activeRef) || new Map();
516
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
517
+ pool.state.usageDays.set(activeRef, dayMap);
518
+ }
519
+ // Per-model request detail (#121)
520
+ if (activeRef && options.model) {
521
+ if (!pool.state.byModel) pool.state.byModel = new Map();
522
+ let byRef = pool.state.byModel.get(activeRef);
523
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
524
+ byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
525
+ }
526
+ // #512: Record success metrics, tokens, local quota, limits and proactive pause
527
+ if (activeRef) {
528
+ recordStreamSuccessMetrics(pool, activeRef, options, chunk, {
529
+ now,
530
+ sessionId,
531
+ attempt,
532
+ runtime0,
533
+ quotaManaged,
534
+ schedulePersist,
535
+ logWarn,
536
+ rawHdrs,
537
+ pushEvent,
538
+ quotaStore,
539
+ });
540
+ }
541
+ recordLatency(pool, reqStore);
542
+ activePermit?.success();
543
+ if (isTraceOwner) trace.finish('SUCCESS', activeRef);
544
+ yield chunk;
545
+ return;
546
+ }
547
+ yield chunk;
548
+ }
549
+ } catch (e) {
550
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
551
+ const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
552
+ if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
553
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
554
+ recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
555
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
556
+ pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
557
+ pool.state.lastSwitchAt = now();
558
+ 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`);
560
+ if (switchNotify && activeRef) {
561
+ notifySwitch(runtime0, pool, {
562
+ provider: options.provider,
563
+ from: activeRef,
564
+ code: String(e?.code ?? 'TRANSPORT'),
565
+ at: pool.state.lastSwitchAt,
566
+ });
567
+ }
568
+ continue; // Failover to next key!
569
+ }
570
+ if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
571
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
572
+ }
573
+ yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
574
+ return;
575
+ }
576
+
577
+ if (switching) continue; // try the next key
578
+ if (yielded) {
579
+ const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
580
+ if (activeRef) recordSuccess(pool, activeRef, now());
581
+ recordLatency(pool, reqStore);
582
+ activePermit?.success();
583
+ if (isTraceOwner) trace.finish('SUCCESS', activeRef);
584
+ } else if (isTraceOwner) {
585
+ trace.finish('COMPLETED', reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed);
586
+ }
587
+ return; // clean end — served
588
+ } finally {
589
+ if (reqStore.concurrencyRef) {
590
+ concurrencyTracker?.release(reqStore.concurrencyRef);
591
+ reqStore.concurrencyRef = null;
592
+ }
593
+ reqStore.pickedRef = undefined;
594
+ // Also runs on consumer return(), abort, empty EOF and local errors.
595
+ activePermit?.release();
596
+ }
597
+ }
598
+
599
+ if (isTraceOwner) {
600
+ trace.finish('TERMINATED', reqStore.pickedRef ?? pool.state.lastUsed);
601
+ }
602
+
603
+ // pool exhausted — all keys cooling or missing
604
+ pool.state.lastExhaustionAt = now();
605
+ pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
606
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
607
+ const runtime = buildRuntime();
608
+ // notify via extracted helper (see notifyExhaustion above)
609
+ notifyExhaustion(runtime, pool, { provider: options.provider });
610
+
611
+ // #194 / #364: cross-provider cascade failover with model mapping
612
+ const maxDepth = runtime.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
613
+ const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
614
+ const cascaded = yield* executeCascade(rotate, options, pool, runtime, { budget, trace, currentDepth, maxDepth, reqStore, pushEvent, now, logWarn }, circuitBreaker);
615
+ if (cascaded) return;
616
+
617
+ const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
618
+ yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
619
+ } finally {
620
+ if (isBudgetOwner) {
621
+ budget.dispose();
622
+ }
623
+ }
624
+ })();
625
+ }
626
+
627
+ return rotate;
628
+ }