@goodandready/dsh-key-rotation 0.8.39 → 0.8.40

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.40 - 2026-09-30
4
+
5
+ ### Fixed
6
+ - **Dynamic concurrency limits & least-loaded connection tracking (#407)**:
7
+ - Added `configure({ limit, staleMs })` to `ConcurrencyTracker` so runtime limit updates from configuration or settings take immediate effect.
8
+ - Track in-flight request counts across all keys regardless of whether global limit is 0, ensuring accurate `least-loaded` routing metrics and status telemetry.
9
+ - Atomically acquire in-flight permit at credential selection time in `lib/resolver.js` and roll back immediately if the credential resolver rejects.
10
+ - Safely release concurrency permits in the outer `finally` block of `lib/rotate.js` on stream completion, client abort, error, or unhandled rejection.
11
+ - Skip saturated keys in `pickLeastLoaded` and fail closed when all candidate keys exceed their configured concurrency limits.
12
+ - **Model-aware cross-provider cascade failover (#414)**:
13
+ - `pickCascadeFallback` now maps fallback models to target model sub-pools (`modelPoolByProvider`) rather than falling back only to base provider pools.
14
+ - Supported model-only providers (providers where credentials are defined exclusively under `models`) as valid cascade fallback targets.
15
+ - Validated local model token budgets (`isModelQuotaAvailable`) during cascade selection to bypass exhausted model pools.
16
+ - **Aggregated provider monetary budget & model pool enforcement (#422)**:
17
+ - Added `getProviderCost(provider, runtime, now)` to aggregate daily and weekly spend across provider base pools and all model sub-pools without double-counting shared state.
18
+ - `checkBudgetAndHealthAlerts` now consolidates provider spend across all pools, sends unified webhook alerts with correct provider names, and pauses all pools (base and model sub-pools) for 24h when `pauseOnBudget: true`.
19
+ - Enforced monetary budget refusal (`LOCAL_POOL_EXHAUSTED`) at credential resolution time in `lib/resolver.js` when provider budget is exceeded and `pauseOnBudget` is active.
20
+
3
21
  ## 0.8.39 - 2026-09-30
4
22
 
5
23
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  // lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
2
2
  import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
3
3
  import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
4
+ import { getProviderCost } from './resolver.js';
4
5
 
5
6
  const DAY_MS = 86400000;
6
7
 
@@ -17,58 +18,61 @@ export function checkBudgetAndHealthAlerts({
17
18
  logger,
18
19
  }) {
19
20
  try {
20
- const seen = new Set();
21
- for (const pool of runtime.poolByRef.values()) {
22
- if (seen.has(pool.base)) continue;
23
- seen.add(pool.base);
24
- // #207: keys expiring within expiryWarnDays -> one webhook per key/day
25
- for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
26
- if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
27
- logger?.warn?.(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
28
- if (runtime.notifyWebhook) {
29
- webhookSender.send(runtime.notifyWebhook, {
30
- title: `Key expiring soon: ${pool.base}`,
31
- text: `${ref} expires in ~${expiresInDays} day(s)`,
32
- provider: pool.base,
33
- kind: 'expiry',
34
- keys: [ref],
35
- });
36
- }
37
- }
38
- // #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
39
- const budget = runtime.providerBudgets?.get?.(pool.base);
40
- if (budget) {
41
- const daily = costForDay(pool.state.costDays);
42
- const weekly = costForWeek(pool.state.costDays, now);
21
+ // #208 / #422: provider monetary budget alerts & pause across base and model pools
22
+ if (runtime.providerBudgets && runtime.providerBudgets.size > 0) {
23
+ for (const [provider, budget] of runtime.providerBudgets) {
24
+ const { daily, weekly, pools } = getProviderCost(provider, runtime, now);
43
25
  const verdict = budgetVerdict(daily, budget.costBudgetDaily);
44
26
  const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
45
27
  const hit = verdict.warn || wVerdict.warn;
46
- if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
47
- logger?.warn?.(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
28
+ if (hit && shouldNotifyDaily(budgetNotifiedAt, provider + ':budget', now)) {
29
+ logger?.warn?.(`[dsh-key-rotation] ${provider}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
48
30
  if (runtime.notifyWebhook) {
49
- // #217: budget webhook gains action buttons when a callback token is configured
50
31
  const token = runtime.webhookActionToken ?? '';
51
32
  webhookSender.send(runtime.notifyWebhook, {
52
- title: `Cost budget: ${pool.base}`,
33
+ title: `Cost budget: ${provider}`,
53
34
  text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
54
- provider: pool.base,
35
+ provider,
55
36
  kind: 'budget',
56
37
  spend: { daily, weekly },
57
38
  actionToken: token || undefined,
58
39
  actions: token ? [
59
- { id: `pause-${pool.base}`, label: 'Pause 1h' },
60
- { id: `reset-${pool.base}`, label: 'Reset cooldown' },
40
+ { id: `pause-${provider}`, label: 'Pause 1h' },
41
+ { id: `reset-${provider}`, label: 'Reset cooldown' },
61
42
  ] : undefined,
62
43
  });
63
44
  }
64
45
  }
65
46
  if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
66
47
  const until = now + DAY_MS;
67
- for (const ref of pool.refs) {
68
- if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
48
+ for (const p of pools) {
49
+ for (const ref of p.refs) {
50
+ if ((p.state.failedUntil.get(ref) ?? 0) < until) p.state.failedUntil.set(ref, until);
51
+ }
69
52
  }
70
53
  }
71
54
  }
55
+ }
56
+
57
+ const seen = new Set();
58
+ for (const pool of runtime.poolByRef.values()) {
59
+ if (seen.has(pool.base)) continue;
60
+ seen.add(pool.base);
61
+ // #207: keys expiring within expiryWarnDays -> one webhook per key/day
62
+ for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
63
+ if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
64
+ logger?.warn?.(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
65
+ if (runtime.notifyWebhook) {
66
+ webhookSender.send(runtime.notifyWebhook, {
67
+ title: `Key expiring soon: ${pool.base}`,
68
+ text: `${ref} expires in ~${expiresInDays} day(s)`,
69
+ provider: pool.base,
70
+ kind: 'expiry',
71
+ keys: [ref],
72
+ });
73
+ }
74
+ }
75
+ // #208 / #422: handled provider-wide before pool loop
72
76
  // #221: pool running low - webhook while healthy < warnBelowHealthy
73
77
  const warnBelow = runtime.warnBelowHealthy ?? 0;
74
78
  if (warnBelow > 0) {
package/lib/cascade.js CHANGED
@@ -1,28 +1,41 @@
1
- // cascade.js — cross-provider failover cascade (issues #194, #364, #376).
1
+ // cascade.js — cross-provider failover cascade (issues #194, #364, #376, #414).
2
2
  // ponytail: minimal — pick fallback provider with model mapping from config.
3
- import { isKeyPaused, isKeyRevoked } from './pool.js';
3
+ import { isKeyPaused, isKeyRevoked, selectPool } from './pool.js';
4
+ import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
4
5
 
5
6
  export const CASCADE_MAX_DEPTH = 3;
6
7
 
7
- export function pickCascadeFallback(provider, cfg, pools, currentModel) {
8
+ export function pickCascadeFallback(provider, cfg, pools, currentModel, modelPoolByProvider) {
8
9
  const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
10
+ const modelPools = modelPoolByProvider || cfg?.modelPoolByProvider || cfg?.index?.modelPoolByProvider || null;
11
+ const provPools = pools || cfg?.providerToPool || cfg?.index?.providerToPool || null;
12
+
9
13
  for (const entry of list) {
10
14
  const fb = typeof entry === 'string' ? { provider: entry } : entry;
11
15
  if (!fb || !fb.provider || fb.provider === provider) continue;
12
- const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
16
+
17
+ const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
18
+ let pool = null;
19
+ if (modelPools && mappedModel) {
20
+ pool = selectPool(modelPools, provPools, fb.provider, mappedModel);
21
+ }
22
+ if (!pool && provPools) {
23
+ pool = provPools instanceof Map ? provPools.get(fb.provider) : (provPools ? provPools[fb.provider] : null);
24
+ }
13
25
  if (!pool) continue;
26
+
14
27
  const now = Date.now();
15
28
  let healthy = 0;
16
29
  for (const ref of pool.refs) {
17
30
  if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
18
31
  const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
19
32
  if (failedUntil > now) continue;
20
- const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
33
+ const exp = pool.expiresAt ? pool.expiresAt[ref] : (pool.basePool?.expiresAt ? pool.basePool.expiresAt[ref] : undefined);
21
34
  if (exp !== undefined && now >= exp) continue;
35
+ if (hasModelQuotaConfig(pool) && !isModelQuotaAvailable(pool, ref, now)) continue;
22
36
  healthy += 1;
23
37
  }
24
38
  if (healthy === 0) continue;
25
- const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
26
39
  return { provider: fb.provider, pool, model: mappedModel };
27
40
  }
28
41
  return null;
@@ -1,4 +1,4 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
2
2
 
3
3
  const CONCURRENCY_DEFAULT_LIMIT = 0;
4
4
  const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
@@ -12,12 +12,22 @@ export class ConcurrencyTracker {
12
12
  this._inFlight = new Map();
13
13
  }
14
14
 
15
+ configure(opts) {
16
+ if (!opts) return;
17
+ if (opts.limit !== undefined) {
18
+ this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
19
+ }
20
+ if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
21
+ this._staleMs = opts.staleMs;
22
+ }
23
+ }
24
+
15
25
  isEnabled() { return this._limit > 0; }
16
26
  get limit() { return this._limit; }
17
27
 
18
- acquire(ref, now) {
28
+ acquire(ref, now, limit) {
19
29
  now = now || Date.now();
20
- if (!this.isEnabled()) return true;
30
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
21
31
  let e = this._inFlight.get(ref);
22
32
  if (!e) {
23
33
  e = { count: 0, lastAcquired: now };
@@ -26,7 +36,7 @@ export class ConcurrencyTracker {
26
36
  if (now - e.lastAcquired > this._staleMs) {
27
37
  e.count = 0;
28
38
  }
29
- if (e.count >= this._limit) return false;
39
+ if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
30
40
  e.count += 1;
31
41
  e.lastAcquired = now;
32
42
  return true;
@@ -55,8 +65,9 @@ export class ConcurrencyTracker {
55
65
  return out;
56
66
  }
57
67
 
58
- pickLeastLoaded(candidates, now, weights) {
68
+ pickLeastLoaded(candidates, now, weights, limit) {
59
69
  now = now || Date.now();
70
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
60
71
  if (!Array.isArray(candidates) || candidates.length === 0) return null;
61
72
  let best = null;
62
73
  let bestScore = Infinity;
@@ -64,7 +75,7 @@ export class ConcurrencyTracker {
64
75
  const e = this._inFlight.get(ref);
65
76
  let count = e ? e.count : 0;
66
77
  if (e && now - e.lastAcquired > this._staleMs) count = 0;
67
- if (this.isEnabled() && count >= this._limit) continue;
78
+ if (effectiveLimit > 0 && count >= effectiveLimit) continue;
68
79
  const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
69
80
  const score = count / w;
70
81
  if (score < bestScore) {
package/lib/index.js CHANGED
@@ -324,6 +324,7 @@ export function apply(ctx, config = {}) {
324
324
  budgetNotifiedAt,
325
325
  });
326
326
 
327
+ concurrencyTracker.configure({ limit: concurrencyLimit });
327
328
  cachedRuntime = {
328
329
  switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold,
329
330
  concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, tpmLimit,
@@ -355,9 +356,12 @@ export function apply(ctx, config = {}) {
355
356
  const resolve = createResolver({
356
357
  buildRuntime,
357
358
  currentPool: () => dispatchStorage.getStore()?.pool,
358
- onPicked: (pool, candidate) => {
359
+ onPicked: (pool, candidate, meta) => {
359
360
  const store = dispatchStorage.getStore();
360
- if (store && store.pool === pool) store.pickedRef = candidate;
361
+ if (store && store.pool === pool) {
362
+ store.pickedRef = candidate;
363
+ if (meta?.concurrencyRef) store.concurrencyRef = meta.concurrencyRef;
364
+ }
361
365
  },
362
366
  latencyHistogram,
363
367
  concurrencyTracker,
@@ -27,6 +27,7 @@ export function buildPoolItem({
27
27
  poolGuard,
28
28
  rpmLimit,
29
29
  tpmLimit = 0,
30
+ concurrencyLimit = 0,
30
31
  makeState,
31
32
  provider = null,
32
33
  model = null,
@@ -103,6 +104,7 @@ export function buildPoolItem({
103
104
  expiresAt: parsedExpiry,
104
105
  rpmLimit,
105
106
  tpmLimit: typeof tpmLimit === 'number' && Number.isFinite(tpmLimit) && tpmLimit > 0 ? Math.floor(tpmLimit) : 0,
107
+ concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
106
108
  routingStrategy: poolStrategy,
107
109
  proactiveRateLimitGuard: poolGuard,
108
110
  };
@@ -111,6 +113,7 @@ export function buildPoolItem({
111
113
  export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
112
114
  const rpmLimit = cfg.rpmLimit ?? 0;
113
115
  const tpmLimit = cfg.tpmLimit ?? 0;
116
+ const concurrencyLimit = cfg.concurrencyLimit ?? 0;
114
117
  const quotaResetWindow = cfg.quotaResetWindow ?? null;
115
118
  const poolByRef = new Map();
116
119
  const providerToPool = new Map();
@@ -127,10 +130,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
127
130
  return initializePoolState(st);
128
131
  };
129
132
 
130
- const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit) =>
133
+ const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit, pConcurrency = concurrencyLimit) =>
131
134
  buildPoolItem({
132
135
  base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
133
- rpmLimit: pRpm, tpmLimit: pTpm, makeState, quotaResetWindow, ...meta,
136
+ rpmLimit: pRpm, tpmLimit: pTpm, concurrencyLimit: pConcurrency, makeState, quotaResetWindow, ...meta,
134
137
  });
135
138
 
136
139
  for (const p of cfg.providers ?? []) {
@@ -138,10 +141,11 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
138
141
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
139
142
  const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
140
143
  const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
144
+ const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
141
145
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
142
146
  provider: p.provider,
143
147
  model: null,
144
- }, provRpm, provTpm);
148
+ }, provRpm, provTpm, provConcurrency);
145
149
  if (pool) {
146
150
  for (const ref of pool.refs) {
147
151
  if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
@@ -153,6 +157,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
153
157
  for (const [model, mp] of Object.entries(p.models ?? {})) {
154
158
  const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
155
159
  const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
160
+ const modelConcurrency = typeof mp.concurrencyLimit === 'number' ? mp.concurrencyLimit : provConcurrency;
156
161
  const mergedPaused = new Set([
157
162
  ...(pool?.pausedRefs ?? []),
158
163
  ...(Array.isArray(mp.paused) ? mp.paused : (mp.paused instanceof Set ? [...mp.paused] : [])),
@@ -166,7 +171,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
166
171
  provider: p.provider,
167
172
  model,
168
173
  quotas: mp.quotas,
169
- }, modelRpm, modelTpm);
174
+ }, modelRpm, modelTpm, modelConcurrency);
170
175
  if (!mpool) continue;
171
176
  mpool.basePool = pool;
172
177
  byModel.set(model, mpool);
package/lib/resolver.js CHANGED
@@ -1,4 +1,3 @@
1
- // lib/resolver.js — the credential selector behind credentials.resolve (#381).
2
1
  //
3
2
  // Extracted from lib/index.js so the routing decisions that this feature
4
3
  // depends on — request-scoped pool ownership, model token quota enforcement,
@@ -10,7 +9,7 @@
10
9
  // delegates to `original(ref)`.
11
10
 
12
11
  import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
13
- import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked } from './pool.js';
12
+ import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, costForDay, costForWeek, budgetVerdict } from './pool.js';
14
13
  import { decryptSecret } from './crypto-storage.js';
15
14
  import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
16
15
  import {
@@ -24,11 +23,51 @@ import {
24
23
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
25
24
  export const LOCAL_POOL_EXHAUSTED_CODE = 'LOCAL_POOL_EXHAUSTED';
26
25
 
26
+ /**
27
+ * Calculates aggregated daily and weekly spend across base pool and model pools for a provider without double-counting (#422).
28
+ */
29
+ export function getProviderCost(provider, runtime, now = Date.now()) {
30
+ const seenStates = new Set();
31
+ let daily = 0;
32
+ let weekly = 0;
33
+ const pools = [];
34
+ const basePool = runtime?.providerToPool?.get?.(provider);
35
+ if (basePool) pools.push(basePool);
36
+ const modelPools = runtime?.modelPoolByProvider?.get?.(provider);
37
+ if (modelPools) {
38
+ for (const mp of modelPools.values()) {
39
+ if (!pools.includes(mp)) pools.push(mp);
40
+ }
41
+ }
42
+ if (runtime?.index?.allPools) {
43
+ for (const p of runtime.index.allPools) {
44
+ if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
45
+ pools.push(p);
46
+ }
47
+ }
48
+ }
49
+ if (runtime?.poolByRef) {
50
+ for (const p of runtime.poolByRef.values()) {
51
+ if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
52
+ pools.push(p);
53
+ }
54
+ }
55
+ }
56
+ for (const p of pools) {
57
+ if (p.state && !seenStates.has(p.state)) {
58
+ seenStates.add(p.state);
59
+ daily += costForDay(p.state.costDays);
60
+ weekly += costForWeek(p.state.costDays, now);
61
+ }
62
+ }
63
+ return { daily, weekly, pools };
64
+ }
65
+
27
66
  /**
28
67
  * @param {object} deps
29
68
  * @param {() => object} deps.buildRuntime current runtime snapshot
30
69
  * @param {() => object|undefined} deps.currentPool request-scoped pool (ALS)
31
- * @param {(pool: object, candidate: string) => void} [deps.onPicked] picked-ref hook
70
+ * @param {(pool: object, candidate: string, meta?: object) => void} [deps.onPicked] picked-ref hook
32
71
  * @param {object} [deps.latencyHistogram]
33
72
  * @param {object} [deps.concurrencyTracker]
34
73
  * @param {() => number} [deps.now] wall clock; injectable for tests
@@ -60,6 +99,22 @@ export function createResolver({
60
99
  }
61
100
 
62
101
  const at = now();
102
+
103
+ // Provider monetary budget refusal (#422):
104
+ const providerName = pool.provider ?? pool.base?.split?.('::')?.[0];
105
+ const budget = runtime.providerBudgets?.get?.(providerName);
106
+ if (budget && budget.pauseOnBudget) {
107
+ const { daily, weekly } = getProviderCost(providerName, runtime, at);
108
+ const vDaily = budgetVerdict(daily, budget.costBudgetDaily);
109
+ const vWeekly = budgetVerdict(weekly, budget.costBudgetWeekly);
110
+ if (vDaily.exceeded || vWeekly.exceeded) {
111
+ throw Object.assign(
112
+ new Error(`dsh-key-rotation: provider "${providerName}" cost budget exceeded (daily: $${daily.toFixed(2)}/${budget.costBudgetDaily}, weekly: $${weekly.toFixed(2)}/${budget.costBudgetWeekly})`),
113
+ { code: LOCAL_POOL_EXHAUSTED_CODE, localExhausted: true },
114
+ );
115
+ }
116
+ }
117
+
63
118
  const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
64
119
  let list = pool.weightedRefs ?? pool.refs;
65
120
  if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
@@ -82,9 +137,6 @@ export function createResolver({
82
137
  const candidateExp = pool.expiresAt?.[candidate] ?? pool.basePool?.expiresAt?.[candidate];
83
138
  if (candidateExp !== undefined && at >= candidateExp) continue;
84
139
  // Local model token budget: a spent credential is simply not a candidate.
85
- // This is deliberately NOT recorded as a failure — the key is healthy and
86
- // merely out of budget — so it must never touch failedUntil / failCounts /
87
- // authFailCounts / brokenUntil.
88
140
  if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
89
141
  const rpmLimit = pool.rpmLimit ?? 0;
90
142
  if (rpmLimit > 0) {
@@ -119,20 +171,46 @@ export function createResolver({
119
171
  }
120
172
  }
121
173
 
174
+ // Concurrency limit per-key check (#407):
175
+ const concurrencyLimit = pool.concurrencyLimit ?? runtime.concurrencyLimit ?? 0;
176
+ if (concurrencyLimit > 0 && concurrencyTracker && typeof concurrencyTracker.getActive === 'function') {
177
+ if (concurrencyTracker.getActive(candidate, at) >= concurrencyLimit) {
178
+ continue;
179
+ }
180
+ }
181
+
182
+ // Atomically acquire concurrency permit upon candidate selection (#407):
183
+ let acquired = false;
184
+ if (concurrencyTracker && typeof concurrencyTracker.acquire === 'function') {
185
+ const ok = concurrencyTracker.acquire(candidate, at, concurrencyLimit);
186
+ if (!ok) continue;
187
+ acquired = true;
188
+ }
189
+
122
190
  // Advance the shared cursor at selection time: concurrent requests must
123
191
  // not all pick the same slot.
124
192
  pool.state.pointer = (index + 1) % list.length;
125
- const hit = await original(candidate);
126
- if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
127
- settle(pool, candidate, at, onPicked);
128
- return { ...hit, value: decryptSecret(hit.value) };
193
+ try {
194
+ const hit = await original(candidate);
195
+ if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
196
+ settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
197
+ return { ...hit, value: decryptSecret(hit.value) };
198
+ }
199
+ // No stored credential: fall back to the launching environment, which is
200
+ // how env-bootstrapped pools work (issue #7).
201
+ const envVal = envValue(candidate);
202
+ if (envVal !== undefined) {
203
+ settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
204
+ return { value: decryptSecret(envVal), source: 'env' };
205
+ }
206
+ } catch (err) {
207
+ if (acquired && typeof concurrencyTracker.release === 'function') {
208
+ concurrencyTracker.release(candidate, at);
209
+ }
210
+ throw err;
129
211
  }
130
- // No stored credential: fall back to the launching environment, which is
131
- // how env-bootstrapped pools work (issue #7).
132
- const envVal = envValue(candidate);
133
- if (envVal !== undefined) {
134
- settle(pool, candidate, at, onPicked);
135
- return { value: decryptSecret(envVal), source: 'env' };
212
+ if (acquired && typeof concurrencyTracker.release === 'function') {
213
+ concurrencyTracker.release(candidate, at);
136
214
  }
137
215
  }
138
216
 
@@ -154,11 +232,11 @@ export function createResolver({
154
232
  }
155
233
 
156
234
  /** Record a successful credential selection on the pool and request scope. */
157
- function settle(pool, candidate, now, onPicked) {
235
+ function settle(pool, candidate, now, onPicked, concurrencyRef) {
158
236
  pool.state.lastUsed = candidate;
159
237
  if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
160
238
  pool.state.lastUsedAt.set(candidate, now);
161
- onPicked?.(pool, candidate);
239
+ onPicked?.(pool, candidate, { concurrencyRef });
162
240
  pool.state.failCounts?.delete(candidate);
163
241
  pool.state.failedUntil.delete(candidate);
164
242
  pool.state.authFailCounts?.delete(candidate);
package/lib/rotate.js CHANGED
@@ -80,7 +80,7 @@ export function createRotate(deps) {
80
80
  let lastFailure = null;
81
81
  const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
82
82
  let activePermit = null;
83
- const reqStore = { pool, pickedRef: undefined, startMs: now() };
83
+ const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now() };
84
84
  setRotateStartMs(reqStore.startMs);
85
85
  const wallNow = Date.now();
86
86
  const quotaManaged = hasModelQuotaConfig(pool);
@@ -97,7 +97,7 @@ export function createRotate(deps) {
97
97
  const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
98
98
  const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
99
99
  if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
100
- const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model);
100
+ const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model, runtimeQ.modelPoolByProvider);
101
101
  if (fb && fb.pool && fb.pool !== pool) {
102
102
  pool.state.lastReason = 'CASCADE';
103
103
  pool.state.lastSwitchAt = now();
@@ -225,7 +225,10 @@ export function createRotate(deps) {
225
225
  }
226
226
 
227
227
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
228
- const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
228
+ if (_pickedRef && !reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
229
+ const ok = concurrencyTracker.acquire(_pickedRef, now(), pool.concurrencyLimit ?? runtime0.concurrencyLimit ?? 0);
230
+ if (ok) reqStore.concurrencyRef = _pickedRef;
231
+ }
229
232
  try {
230
233
  try {
231
234
  for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
@@ -398,11 +401,12 @@ export function createRotate(deps) {
398
401
  }
399
402
  return; // clean end — served
400
403
  } finally {
401
- if (acquired && _pickedRef) {
402
- concurrencyTracker.release(_pickedRef);
403
- }
404
404
  }
405
405
  } finally {
406
+ if (reqStore.concurrencyRef) {
407
+ concurrencyTracker?.release(reqStore.concurrencyRef);
408
+ reqStore.concurrencyRef = null;
409
+ }
406
410
  // Also runs on consumer return(), abort, empty EOF and local errors.
407
411
  activePermit?.release();
408
412
  }
@@ -421,7 +425,7 @@ export function createRotate(deps) {
421
425
  const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
422
426
  if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
423
427
  const pools = runtime.providerToPool;
424
- const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
428
+ const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider);
425
429
  if (fb && fb.pool && fb.pool !== pool) {
426
430
  logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
427
431
  pool.state.lastReason = 'CASCADE';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.39",
3
+ "version": "0.8.40",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [