@goodandready/dsh-key-rotation 0.8.68 → 0.8.70

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