@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/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, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
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 list = pool.weightedRefs ?? pool.refs;
688
- const start = pool.state.pointer ?? 0;
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 lk = k.length;
254
- if (lk === 21 || lk === 16 || lk === 17) {
255
- const lower = k.toLowerCase();
256
- if (lower === 'x-ratelimit-remaining') {
257
- const v = headers[k];
258
- if (v != null) remaining = Number(String(v));
259
- } else if (lower === 'x-ratelimit-limit') {
260
- const v = headers[k];
261
- if (v != null) limit = Number(String(v));
262
- } else if (lower === 'x-ratelimit-reset') {
263
- const v = headers[k];
264
- if (v != null) reset = Number(String(v));
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
- return { remaining, limit, reset };
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.remaining === undefined) return false;
276
- if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
277
- return rate.remaining <= 0;
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
- if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
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
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
124
+ const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
119
125
  try {
120
- for await (const chunk of inner) {
121
- // Only actual content deltas lock the stream (no more rotation).
122
- // Structural/metadata chunks (block-start/end, usage) do not.
123
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
124
- yielded = true;
125
- yield chunk;
126
- continue;
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
- // cost tracking if provider returns usage.cost
156
- const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
157
- if (chunk.usage?.cost != null && activeRef) {
158
- const c = Number(chunk.usage.cost);
159
- if (!isNaN(c)) {
160
- if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
161
- pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
162
- // #208: cost per day per key (mirrors usageDays) for budget checks
163
- if (!pool.state.costDays) pool.state.costDays = new Map();
164
- const cMap = pool.state.costDays.get(activeRef) || new Map();
165
- cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
166
- pool.state.costDays.set(activeRef, cMap);
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
- // Usage by day (#119)
170
- if (activeRef) {
171
- if (!pool.state.usageDays) pool.state.usageDays = new Map();
172
- const dayMap = pool.state.usageDays.get(activeRef) || new Map();
173
- dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
174
- pool.state.usageDays.set(activeRef, dayMap);
175
- }
176
- // Per-model request detail (#121)
177
- if (activeRef && options.model) {
178
- if (!pool.state.byModel) pool.state.byModel = new Map();
179
- let byRef = pool.state.byModel.get(activeRef);
180
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
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
- // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
196
- if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
197
- quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
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
- yield chunk;
205
- }
206
- } catch (e) {
207
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
208
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
209
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
210
- if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
211
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
212
- pool.state.switches = (pool.state.switches ?? 0) + 1;
213
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
214
- pool.state.lastSwitchAt = now();
215
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
216
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
217
- if (switchNotify && activeRef) {
218
- notifySwitch(runtime0, pool, {
219
- provider: options.provider,
220
- from: activeRef,
221
- code: String(e?.code ?? 'TRANSPORT'),
222
- at: pool.state.lastSwitchAt,
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
- continue; // Failover to next key!
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
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
232
- if (switching) continue; // try the next key
233
- return; // clean end — served
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