@goodandready/dsh-key-rotation 0.8.7 → 0.8.10
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/README.md +22 -3
- package/README.ru.md +22 -3
- package/README.zh.md +22 -4
- package/lib/client.js +244 -5
- package/lib/concurrency.js +9 -0
- package/lib/heal.js +47 -0
- package/lib/index.js +47 -8
- package/lib/pool.js +104 -21
- package/lib/rotate.js +127 -107
- package/lib/routes-ops.js +38 -11
- package/lib/sandbox.js +9 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -27,8 +27,9 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
27
27
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
28
28
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
29
29
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
30
|
+
import { sortAttemptList } from './pool.js';
|
|
30
31
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
31
|
-
import { healIdleCooldowns } from './heal.js';
|
|
32
|
+
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
32
33
|
import { LatencyHistogram } from './histogram.js';
|
|
33
34
|
import { pickCascadeFallback } from './cascade.js';
|
|
34
35
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
@@ -199,6 +200,9 @@ export const Config = Schema.object({
|
|
|
199
200
|
hour: Schema.number().default(0),
|
|
200
201
|
}),
|
|
201
202
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
203
|
+
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
204
|
+
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
205
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']).default('round-robin'),
|
|
202
206
|
rpmLimit: Schema.number().default(0),
|
|
203
207
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
204
208
|
expiryWarnDays: Schema.number().default(7),
|
|
@@ -230,6 +234,8 @@ export const Config = Schema.object({
|
|
|
230
234
|
})).default({}),
|
|
231
235
|
cooldownMs: Schema.number(),
|
|
232
236
|
maxCooldownMs: Schema.number(),
|
|
237
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']),
|
|
238
|
+
proactiveRateLimitGuard: Schema.boolean(),
|
|
233
239
|
})).default([...DEFAULT_PROVIDERS]),
|
|
234
240
|
});
|
|
235
241
|
|
|
@@ -339,6 +345,29 @@ export function apply(ctx, config = {}) {
|
|
|
339
345
|
if (snap) statePersistence.save(snap);
|
|
340
346
|
}
|
|
341
347
|
|
|
348
|
+
// Auto-healing / auto-unbreak for broken keys (#303)
|
|
349
|
+
ctx.effect(() => {
|
|
350
|
+
const cfg = getConfig();
|
|
351
|
+
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
352
|
+
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
353
|
+
const intervalMs = intervalMin * 60 * 1000;
|
|
354
|
+
const timer = setInterval(async () => {
|
|
355
|
+
try {
|
|
356
|
+
const c = getConfig();
|
|
357
|
+
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
358
|
+
const { pools } = buildRuntime();
|
|
359
|
+
const runner = ensureSandboxRunner(ctx);
|
|
360
|
+
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
361
|
+
let val = (await ctx.credentials?.resolve?.(ref))?.value;
|
|
362
|
+
if (!val) return { ok: false };
|
|
363
|
+
return runner.probeModels(ref, val);
|
|
364
|
+
});
|
|
365
|
+
} catch (_) {}
|
|
366
|
+
}, intervalMs);
|
|
367
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
368
|
+
return () => clearInterval(timer);
|
|
369
|
+
}, 'dsh-key-rotation: auto-unbreak');
|
|
370
|
+
|
|
342
371
|
ctx.effect(() => {
|
|
343
372
|
const timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
|
|
344
373
|
if (typeof timer.unref === 'function') timer.unref();
|
|
@@ -372,9 +401,10 @@ export function apply(ctx, config = {}) {
|
|
|
372
401
|
}
|
|
373
402
|
}
|
|
374
403
|
}
|
|
375
|
-
const n = sweepExpired(poolState, now);
|
|
376
|
-
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
377
404
|
const runtime = buildRuntime();
|
|
405
|
+
const allActiveRefs = Array.from(runtime.poolByRef.keys());
|
|
406
|
+
const n = sweepExpired(poolState, now, allActiveRefs);
|
|
407
|
+
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
378
408
|
for (const pool of runtime.poolByRef.values()) {
|
|
379
409
|
compactUsage(pool, 30, now);
|
|
380
410
|
}
|
|
@@ -564,7 +594,7 @@ export function apply(ctx, config = {}) {
|
|
|
564
594
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
565
595
|
return undefined;
|
|
566
596
|
};
|
|
567
|
-
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
597
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) => {
|
|
568
598
|
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
569
599
|
if (refs.length === 0) return null;
|
|
570
600
|
const w = Array.isArray(weights) ? weights : [];
|
|
@@ -582,7 +612,7 @@ export function apply(ctx, config = {}) {
|
|
|
582
612
|
}
|
|
583
613
|
return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
584
614
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
585
|
-
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
|
|
615
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit, routingStrategy: poolStrategy, proactiveRateLimitGuard: poolGuard };
|
|
586
616
|
};
|
|
587
617
|
for (const p of cfg.providers ?? []) {
|
|
588
618
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
@@ -656,7 +686,10 @@ export function apply(ctx, config = {}) {
|
|
|
656
686
|
}
|
|
657
687
|
}
|
|
658
688
|
}
|
|
659
|
-
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
689
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
690
|
+
proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
|
|
691
|
+
selfHealingIntervalMinutes: cfg.selfHealingIntervalMinutes ?? 30,
|
|
692
|
+
routingStrategy: cfg.routingStrategy ?? 'round-robin', switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
|
|
660
693
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
661
694
|
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
662
695
|
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
@@ -684,8 +717,12 @@ export function apply(ctx, config = {}) {
|
|
|
684
717
|
const pool = poolByRef.get(ref);
|
|
685
718
|
if (!pool) return original(ref);
|
|
686
719
|
const now = Date.now();
|
|
687
|
-
const
|
|
688
|
-
|
|
720
|
+
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
721
|
+
let list = pool.weightedRefs ?? pool.refs;
|
|
722
|
+
if (strat === 'lowest-latency' || strat === 'least-loaded') {
|
|
723
|
+
list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
|
|
724
|
+
}
|
|
725
|
+
const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
|
|
689
726
|
for (let i = 0; i < list.length; i++) {
|
|
690
727
|
const index = (start + i) % list.length;
|
|
691
728
|
const candidate = list[index];
|
|
@@ -805,6 +842,7 @@ export function apply(ctx, config = {}) {
|
|
|
805
842
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
806
843
|
notifyExhaustion,
|
|
807
844
|
recordLatency,
|
|
845
|
+
latencyHistogram,
|
|
808
846
|
concurrencyTracker,
|
|
809
847
|
MARKER,
|
|
810
848
|
finishError,
|
|
@@ -827,6 +865,7 @@ export function apply(ctx, config = {}) {
|
|
|
827
865
|
getRotationDisabled: () => rotationDisabled,
|
|
828
866
|
setRotationDisabled: (v) => { rotationDisabled = v; },
|
|
829
867
|
circuitBreaker: moduleBreaker,
|
|
868
|
+
quotaStore,
|
|
830
869
|
});
|
|
831
870
|
|
|
832
871
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
package/lib/pool.js
CHANGED
|
@@ -184,9 +184,10 @@ export function envValue(ref) {
|
|
|
184
184
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
/** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
|
|
188
|
-
export function sweepExpired(poolState, now = Date.now()) {
|
|
187
|
+
/** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
|
|
188
|
+
export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
189
189
|
let cleared = 0;
|
|
190
|
+
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
190
191
|
for (const st of poolState.values()) {
|
|
191
192
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
192
193
|
if (until <= now) {
|
|
@@ -195,6 +196,16 @@ export function sweepExpired(poolState, now = Date.now()) {
|
|
|
195
196
|
cleared++;
|
|
196
197
|
}
|
|
197
198
|
}
|
|
199
|
+
// Prune stale entries for keys no longer in active configuration
|
|
200
|
+
if (activeSet) {
|
|
201
|
+
for (const map of [st.failedUntil, st.failCounts, st.authFailCounts, st.brokenUntil, st.costPerKey, st.lastUsedAt, st.usageCounts, st.byModel, st.usageDays, st.quotaWindows, st.rpmWindows, st.probedAt]) {
|
|
202
|
+
if (map && typeof map.keys === 'function') {
|
|
203
|
+
for (const k of [...map.keys()]) {
|
|
204
|
+
if (!activeSet.has(k)) map.delete(k);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
198
209
|
}
|
|
199
210
|
return cleared;
|
|
200
211
|
}
|
|
@@ -248,33 +259,105 @@ export function computeHealthScore(state) {
|
|
|
248
259
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
249
260
|
export function extractRateLimit(headers) {
|
|
250
261
|
if (!headers || typeof headers !== 'object') return null;
|
|
251
|
-
let remaining, limit, reset;
|
|
262
|
+
let remaining, limit, reset, retryAfter;
|
|
252
263
|
for (const k in headers) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
const lower = k.toLowerCase();
|
|
265
|
+
const v = headers[k];
|
|
266
|
+
if (v == null) continue;
|
|
267
|
+
if (lower === 'retry-after') {
|
|
268
|
+
const num = Number(v);
|
|
269
|
+
if (Number.isFinite(num) && num >= 0) {
|
|
270
|
+
retryAfter = Math.ceil(num);
|
|
271
|
+
} else {
|
|
272
|
+
const parsed = Date.parse(v);
|
|
273
|
+
if (!Number.isNaN(parsed)) {
|
|
274
|
+
retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (
|
|
278
|
+
lower === 'x-ratelimit-remaining' ||
|
|
279
|
+
lower === 'x-ratelimit-remaining-requests' ||
|
|
280
|
+
lower === 'anthropic-ratelimit-requests-remaining' ||
|
|
281
|
+
lower === 'openai-ratelimit-remaining-requests' ||
|
|
282
|
+
lower === 'ratelimit-remaining'
|
|
283
|
+
) {
|
|
284
|
+
const n = Number(String(v));
|
|
285
|
+
if (Number.isFinite(n)) remaining = n;
|
|
286
|
+
} else if (
|
|
287
|
+
(lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
|
|
288
|
+
remaining === undefined
|
|
289
|
+
) {
|
|
290
|
+
const n = Number(String(v));
|
|
291
|
+
if (Number.isFinite(n)) remaining = n;
|
|
292
|
+
} else if (
|
|
293
|
+
lower === 'x-ratelimit-limit' ||
|
|
294
|
+
lower === 'x-ratelimit-limit-requests' ||
|
|
295
|
+
lower === 'anthropic-ratelimit-requests-limit' ||
|
|
296
|
+
lower === 'openai-ratelimit-limit-requests' ||
|
|
297
|
+
lower === 'ratelimit-limit'
|
|
298
|
+
) {
|
|
299
|
+
const n = Number(String(v));
|
|
300
|
+
if (Number.isFinite(n)) limit = n;
|
|
301
|
+
} else if (
|
|
302
|
+
lower === 'x-ratelimit-reset' ||
|
|
303
|
+
lower === 'x-ratelimit-reset-requests' ||
|
|
304
|
+
lower === 'anthropic-ratelimit-requests-reset' ||
|
|
305
|
+
lower === 'ratelimit-reset'
|
|
306
|
+
) {
|
|
307
|
+
const n = Number(String(v));
|
|
308
|
+
if (Number.isFinite(n)) {
|
|
309
|
+
reset = n;
|
|
310
|
+
} else {
|
|
311
|
+
const parsed = Date.parse(v);
|
|
312
|
+
if (!Number.isNaN(parsed)) reset = parsed;
|
|
265
313
|
}
|
|
266
314
|
}
|
|
267
315
|
}
|
|
268
|
-
if (remaining === undefined && limit === undefined) return null;
|
|
269
|
-
|
|
316
|
+
if (remaining === undefined && limit === undefined && retryAfter === undefined && reset === undefined) return null;
|
|
317
|
+
const out = { remaining, limit, reset };
|
|
318
|
+
if (retryAfter !== undefined) out.retryAfter = retryAfter;
|
|
319
|
+
return out;
|
|
270
320
|
}
|
|
271
321
|
|
|
272
|
-
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
|
|
322
|
+
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1) or retry-after is active. */
|
|
273
323
|
export function isRateLimited(rate, threshold = 0.1) {
|
|
274
324
|
if (!rate) return false;
|
|
275
|
-
if (rate.
|
|
276
|
-
if (rate.
|
|
277
|
-
|
|
325
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) return true;
|
|
326
|
+
if (rate.remaining !== undefined && rate.remaining <= 1) return true;
|
|
327
|
+
if (rate.limit && rate.limit > 0 && rate.remaining !== undefined) {
|
|
328
|
+
return rate.remaining < rate.limit * threshold;
|
|
329
|
+
}
|
|
330
|
+
if (rate.remaining !== undefined) return rate.remaining <= 0;
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Sort or prioritize an attempt list of refs according to routing strategy.
|
|
336
|
+
* Strategies:
|
|
337
|
+
* - 'round-robin': preserves order
|
|
338
|
+
* - 'least-loaded': keys with lowest active concurrency count first
|
|
339
|
+
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
340
|
+
*/
|
|
341
|
+
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
342
|
+
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
343
|
+
const list = refs.slice();
|
|
344
|
+
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
345
|
+
return list.sort((a, b) => {
|
|
346
|
+
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
347
|
+
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
348
|
+
return ca - cb;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
352
|
+
return list.sort((a, b) => {
|
|
353
|
+
const sa = deps.latencyHistogram.snapshot(a);
|
|
354
|
+
const sb = deps.latencyHistogram.snapshot(b);
|
|
355
|
+
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
356
|
+
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
357
|
+
return la - lb;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return list;
|
|
278
361
|
}
|
|
279
362
|
|
|
280
363
|
/**
|
package/lib/rotate.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
extractRateLimit,
|
|
10
10
|
isRateLimited,
|
|
11
11
|
formatExhaustionMessage,
|
|
12
|
+
sortAttemptList,
|
|
12
13
|
} from './pool.js';
|
|
13
14
|
import { pickCascadeFallback } from './cascade.js';
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ export function createRotate(deps) {
|
|
|
25
26
|
notifySwitch,
|
|
26
27
|
notifyExhaustion,
|
|
27
28
|
recordLatency,
|
|
29
|
+
latencyHistogram,
|
|
28
30
|
concurrencyTracker,
|
|
29
31
|
MARKER,
|
|
30
32
|
finishError,
|
|
@@ -42,7 +44,11 @@ export function createRotate(deps) {
|
|
|
42
44
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
43
45
|
setRotateStartMs(reqStore.startMs);
|
|
44
46
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
45
|
-
|
|
47
|
+
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
48
|
+
if (strategy !== 'round-robin') {
|
|
49
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
|
|
50
|
+
}
|
|
51
|
+
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
46
52
|
// #193: prefer least-loaded key within limit
|
|
47
53
|
const available = attemptList.filter((r) => {
|
|
48
54
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
@@ -115,122 +121,136 @@ export function createRotate(deps) {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
118
|
-
|
|
124
|
+
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
119
125
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (chunk && chunk.type === 'finish') {
|
|
129
|
-
const kind = chunk.reason?.kind;
|
|
130
|
-
const failure = chunk.reason?.failure;
|
|
131
|
-
const code = failure?.code;
|
|
132
|
-
const message = failure?.message ?? '';
|
|
133
|
-
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
134
|
-
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
135
|
-
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
136
|
-
if (switchable) {
|
|
137
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
138
|
-
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
139
|
-
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
140
|
-
pool.state.lastSwitchAt = now();
|
|
141
|
-
lastFailure = chunk;
|
|
142
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
143
|
-
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
144
|
-
if (switchNotify && activeRef) {
|
|
145
|
-
notifySwitch(runtime0, pool, {
|
|
146
|
-
provider: options.provider,
|
|
147
|
-
from: activeRef,
|
|
148
|
-
code: String(code ?? 'UNKNOWN'),
|
|
149
|
-
at: pool.state.lastSwitchAt,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
switching = true;
|
|
153
|
-
break;
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of inner) {
|
|
128
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
129
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
130
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
131
|
+
yielded = true;
|
|
132
|
+
yield chunk;
|
|
133
|
+
continue;
|
|
154
134
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
pool.state.
|
|
135
|
+
if (chunk && chunk.type === 'finish') {
|
|
136
|
+
const kind = chunk.reason?.kind;
|
|
137
|
+
const failure = chunk.reason?.failure;
|
|
138
|
+
const code = failure?.code;
|
|
139
|
+
const message = failure?.message ?? '';
|
|
140
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
141
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
142
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
143
|
+
if (switchable) {
|
|
144
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
145
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
146
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
147
|
+
pool.state.lastSwitchAt = now();
|
|
148
|
+
lastFailure = chunk;
|
|
149
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
150
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
151
|
+
if (switchNotify && activeRef) {
|
|
152
|
+
notifySwitch(runtime0, pool, {
|
|
153
|
+
provider: options.provider,
|
|
154
|
+
from: activeRef,
|
|
155
|
+
code: String(code ?? 'UNKNOWN'),
|
|
156
|
+
at: pool.state.lastSwitchAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
switching = true;
|
|
160
|
+
break;
|
|
167
161
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
182
|
-
}
|
|
183
|
-
// Proactive rate-limit (#115): if response headers say this key is near
|
|
184
|
-
// its quota, cool it down so the NEXT request starts on a different key.
|
|
185
|
-
// We do NOT re-run this (already successful) request — that would double-send.
|
|
186
|
-
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
187
|
-
if (rate && activeRef) {
|
|
188
|
-
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
189
|
-
const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
|
|
190
|
-
recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
|
|
191
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
192
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
162
|
+
// cost tracking if provider returns usage.cost
|
|
163
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
164
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
165
|
+
const c = Number(chunk.usage.cost);
|
|
166
|
+
if (!isNaN(c)) {
|
|
167
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
168
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
169
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
170
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
171
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
172
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
173
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
174
|
+
}
|
|
193
175
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
176
|
+
// Usage by day (#119)
|
|
177
|
+
if (activeRef) {
|
|
178
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
179
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
180
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
181
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
182
|
+
}
|
|
183
|
+
// Per-model request detail (#121)
|
|
184
|
+
if (activeRef && options.model) {
|
|
185
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
186
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
187
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
188
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
191
|
+
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
192
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
193
|
+
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
194
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
195
|
+
if (guardEnabled && rate && activeRef) {
|
|
196
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
197
|
+
let cool;
|
|
198
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
199
|
+
cool = rate.retryAfter * 1000;
|
|
200
|
+
} else if (rate.reset && rate.reset > now()) {
|
|
201
|
+
cool = rate.reset - now();
|
|
202
|
+
} else {
|
|
203
|
+
cool = pool.cooldownMs ?? cooldownMs;
|
|
204
|
+
}
|
|
205
|
+
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
206
|
+
const effCool = Math.min(cool, maxCool ?? cool);
|
|
207
|
+
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
208
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
209
|
+
console.warn(`[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`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
213
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
214
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
215
|
+
}
|
|
216
|
+
yield chunk;
|
|
217
|
+
recordLatency(pool, reqStore);
|
|
218
|
+
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
219
|
+
return;
|
|
198
220
|
}
|
|
199
221
|
yield chunk;
|
|
200
|
-
recordLatency(pool, reqStore);
|
|
201
|
-
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
202
|
-
return;
|
|
203
222
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
225
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
226
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
227
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
229
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
230
|
+
pool.state.lastSwitchAt = now();
|
|
231
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
232
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
233
|
+
if (switchNotify && activeRef) {
|
|
234
|
+
notifySwitch(runtime0, pool, {
|
|
235
|
+
provider: options.provider,
|
|
236
|
+
from: activeRef,
|
|
237
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
238
|
+
at: pool.state.lastSwitchAt,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
continue; // Failover to next key!
|
|
224
242
|
}
|
|
225
|
-
|
|
243
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
244
|
+
return;
|
|
226
245
|
}
|
|
227
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
247
|
+
if (switching) continue; // try the next key
|
|
248
|
+
return; // clean end — served
|
|
249
|
+
} finally {
|
|
250
|
+
if (acquired && _pickedRef) {
|
|
251
|
+
concurrencyTracker.release(_pickedRef);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
// pool exhausted — all keys cooling or missing
|