@goodandready/dsh-key-rotation 0.8.38 → 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,40 @@
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
+
21
+ ## 0.8.39 - 2026-09-30
22
+
23
+ ### Fixed
24
+ - **SSRF guard: IPv4-mapped IPv6 loopbacks & connect-time rebinding (#411)**:
25
+ - Blocked hex (`::ffff:7f00:1`, `::ffff:7f00:0001`), dotted (`::ffff:127.0.0.1`), IPv4-compatible (`::/96`), and NAT64 (`64:ff9b::/96`) loopback addresses via full 8-word IPv6 unpacking in `lib/safe-fetch.js`.
26
+ - Added connect-time DNS resolution enforcement using `undici.Agent` lookup hook to prevent TOCTOU DNS rebinding before establishing socket connections.
27
+ - **Fail-closed credential resolver on pool exhaustion (#406)**:
28
+ - Return fail-closed error `LOCAL_POOL_EXHAUSTED` instead of leaking unmanaged `original(ref)` when all credentials in a managed pool are exhausted or blocked by cooldown, expiry, RPM, or TPM.
29
+ - **Credential status inheritance & permanent auth revocation (#408)**:
30
+ - Model sub-pools inherit `paused`, `revoked`, and `expiresAt` credentials from their parent base provider pool.
31
+ - Runtime 401 permanent auth failure marks credential as revoked (`revokedRefs.add` and `failedUntil = Infinity`) across all associated pools sharing the ref.
32
+ - **Persistence of revocation state and spend counters (#409)**:
33
+ - `StatePersistence` serializes and restores `revokedRefs` (as a Set), `Infinity` failedUntil timestamps, and daily usage/cost aggregation maps (`costDays`, `costPerKey`, `usageDays`, `usageCounts`).
34
+ - **UI credential alignment and model weights on reordering (#412)**:
35
+ - Preserved `paused` and `revoked` flag alignment with credential references during reordering, deletion, and undo actions in the settings card.
36
+ - Model pool key reordering preserves model-specific weights and expiry mappings.
37
+
3
38
  ## 0.8.38 - 2026-09-30
4
39
 
5
40
  ### Added
package/README.md CHANGED
@@ -207,7 +207,9 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
207
207
  * **Secure Vault Storage**: Actual secret values reside securely in `$DSH_HOME/.credentials.yaml` managed by the DSH `Credentials` service.
208
208
  * **5-Character Masking (`keyTail`)**: Full secret values are never sent to the client browser; only the trailing 5 characters are exposed for visual identification. Short keys (<= 5 characters) return a fixed masked placeholder (`***`) to prevent credential disclosure.
209
209
  * **Fail-Closed Loopback & Same-Origin Fencing**: Administrative endpoints strictly enforce loopback origin checks (`isTrustedBridgeRequest`), requiring valid `Origin` headers, matching `Host` headers, and rejecting `cross-site` or non-loopback requests without fallbacks.
210
- * **SSRF Guard on Import Route**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses against private and reserved ranges (RFC 1918, loopback, link-local, multicast, CGNAT), verifies redirect targets, and caps response payload size to 1 MB.
210
+ * **Fail-Closed Resolver on Pool Exhaustion**: When all credentials in a managed pool are exhausted, paused, expired, or blocked by RPM/TPM limits, the resolver fails closed with `LOCAL_POOL_EXHAUSTED` error rather than falling back to leaking unmanaged credentials.
211
+ * **SSRF Protection & Rebinding Guard**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses including IPv4-mapped and IPv4-compatible IPv6 addresses (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), and enforces connect-time DNS validation via undici agent dispatchers to prevent TOCTOU DNS rebinding.
212
+ * **Cross-Pool Revocation & Inheritance**: Model pools automatically inherit pause, revoke, and expiry states from their base provider; runtime 401 permanent authentication failures revoke the credential across all shared pools immediately.
211
213
 
212
214
  ---
213
215
 
package/README.ru.md CHANGED
@@ -194,7 +194,9 @@ graph LR
194
194
  * **Защищённое хранилище хоста**: Реальные значения ключей сохраняются в `$DSH_HOME/.credentials.yaml` сервисом `Credentials`.
195
195
  * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия. Для коротких ключей (длиной <= 5 символов) возвращается безопасная маскировочная заглушка `***`, исключающая раскрытие ключа целиком.
196
196
  * **Fail-Closed изоляция Loopback и Same-Origin**: Все управляющие эндпоинты строго проверяют происхождение через `isTrustedBridgeRequest`, требуя обязательный заголовок `Origin`, соответствие `Host` и отклоняя запросы `cross-site` и не-loopback без каких-либо исключений.
197
- * **SSRF-защита маршрута импорта**: Маршрут импорта пулов провайдеров разрешает исключительно HTTPS, валидирует все резолвящиеся IP-адреса с блокировкой приватных и зарезервированных диапазонов (RFC 1918, loopback, link-local, multicast, CGNAT), проверяет адреса редиректов и ограничивает размер ответа 1 МБ.
197
+ * **Fail-Closed резолвер при исчерпании пула**: Если все ключи в пуле исчерпаны, приостановлены, истекли или заблокированы по RPM/TPM, резолвер завершается с ошибкой `LOCAL_POOL_EXHAUSTED`, исключая скрытую утечку исходного неконтролируемого ключа.
198
+ * **SSRF-защита и блокировка DNS Rebinding**: Маршрут импорта пулов провайдеров разрешает исключительно HTTPS, валидирует все IP-адреса, включая IPv4-mapped и IPv4-compatible IPv6 представления (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), и выполняет проверку адреса в момент соединения (connect-time DNS validation) через диспетчер undici для защиты от TOCTOU DNS rebinding.
199
+ * **Сквозной отзыв и наследование статусов**: Пул конкретной модели автоматически наследует флаги паузы, отзыва и срок действия базового провайдера; перманентный сбой аутентификации 401 немедленно отзывает ключ во всех связанных пулах.
198
200
 
199
201
  ---
200
202
 
package/README.zh.md CHANGED
@@ -186,7 +186,9 @@ graph LR
186
186
  * **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
187
187
  * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。对于长度 <= 5 的短密钥,返回统一脱敏占位符 (`***`),防止凭证完整泄露。
188
188
  * **Fail-Closed 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 验证本地同源请求,强制要求有效 `Origin` 和 `Host` 匹配,无条件拒绝跨站 (`cross-site`) 与非环回调用。
189
- * **导入接口 SSRF 防护**:远程提供商配置导入严格仅支持 HTTPS,解析并拦截所有私有/保留 IP 地址(RFC 1918、环回、链路本地、多播、CGNAT),对重定向目标进行全程验证,并将响应体上限严格控制在 1 MB 以内。
189
+ * **凭证池耗尽 Fail-Closed 熔断**:当受管凭证池中所有密钥均被耗尽、暂停、过期或触发 RPM/TPM 限制时,解析器立即返回 `LOCAL_POOL_EXHAUSTED` 错误熔断,杜绝静默回退泄漏未受控原始密钥。
190
+ * **SSRF 与 DNS 重绑定防护**:远程配置导入严格仅支持 HTTPS,全面校验解析 IP 并拦截 IPv4-mapped/IPv4-compatible 等各类 IPv6 环回变体(如 `::ffff:127.0.0.1`、`::ffff:7f00:1`、`64:ff9b::/96`),并通过 undici 调度器执行连接时(connect-time)DNS 校验,从根本上防御 TOCTOU DNS 重绑定。
191
+ * **跨池吊销与状态继承**:模型子池自动继承基础提供商的暂停、吊销与过期时间;运行时 401 永久认证失败将即时在所有关联共享池中统一标记吊销。
190
192
 
191
193
  ---
192
194
 
@@ -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;
package/lib/client.js CHANGED
@@ -938,7 +938,7 @@ window.__ModuleLoader__.load({
938
938
 
939
939
  function reorderKeys(entry, order) {
940
940
  const next = { ...entry, keys: order.map(index => entry.keys[index]) };
941
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
941
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
942
942
  if (Array.isArray(entry[field]) && entry[field].length) next[field] = order.map(index => entry[field][index] ?? fallback);
943
943
  }
944
944
  return next;
@@ -1213,7 +1213,7 @@ window.__ModuleLoader__.load({
1213
1213
  const entry = { ...providers[index] };
1214
1214
  const at = Math.min(u.kIndex, entry.keys.length);
1215
1215
  entry.keys = [...entry.keys]; entry.keys.splice(at, 0, u.key);
1216
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
1216
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
1217
1217
  if (u[field] !== undefined || entry[field]?.length) {
1218
1218
  const values = Array.from({ length: entry.keys.length - 1 }, (_, i) => entry[field]?.[i] ?? fallback);
1219
1219
  values.splice(at, 0, u[field] ?? fallback); entry[field] = values;
@@ -1563,7 +1563,7 @@ window.__ModuleLoader__.load({
1563
1563
  const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
1564
1564
  entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
1565
1565
  // keep weights aligned with keys (#215): new key gets default weight 1
1566
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
1566
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
1567
1567
  if (Array.isArray(entry[field]) && entry[field].length) entry[field] = Array.from({ length: entry.keys.length }, (_, i) => entry[field][i] ?? fallback);
1568
1568
  }
1569
1569
  providers[pIndex] = entry;
@@ -1574,7 +1574,8 @@ window.__ModuleLoader__.load({
1574
1574
  const entry = next[pIndex];
1575
1575
  if (!entry || !entry.keys[kIndex]) return cur;
1576
1576
  stashUndo({ type: 'key', provider: entry.provider, kIndex, key: entry.keys[kIndex],
1577
- weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex] });
1577
+ weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex],
1578
+ paused: entry.paused?.[kIndex], revoked: entry.revoked?.[kIndex] });
1578
1579
  next[pIndex] = reorderKeys(entry, entry.keys.map((_, index) => index).filter(index => index !== kIndex));
1579
1580
  return { ...cur, providers: next };
1580
1581
  });
@@ -1691,9 +1692,9 @@ window.__ModuleLoader__.load({
1691
1692
  const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
1692
1693
  const target = kIndex + delta;
1693
1694
  if (target < 0 || target >= keys.length) return;
1694
- [keys[kIndex], keys[target]] = [keys[target], keys[kIndex]];
1695
- mp.keys = keys;
1696
- models[model] = mp;
1695
+ const order = keys.map((_, i) => i);
1696
+ [order[kIndex], order[target]] = [order[target], order[kIndex]];
1697
+ models[model] = reorderKeys(mp, order);
1697
1698
  });
1698
1699
 
1699
1700
  const save = async () => {
@@ -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,
@@ -33,8 +33,17 @@ export class StatePersistence {
33
33
  if (st && st.failedUntil && typeof st.failedUntil.forEach === 'function') {
34
34
  st.failedUntil.forEach((until, ref) => {
35
35
  if (Number.isFinite(until)) failedUntil[ref] = until;
36
+ else if (until === Infinity) failedUntil[ref] = 'Infinity';
36
37
  });
37
38
  }
39
+ const revokedRefs = [];
40
+ if (st && st.revokedRefs) {
41
+ if (typeof st.revokedRefs.forEach === 'function') {
42
+ st.revokedRefs.forEach((r) => revokedRefs.push(r));
43
+ } else if (Array.isArray(st.revokedRefs)) {
44
+ revokedRefs.push(...st.revokedRefs);
45
+ }
46
+ }
38
47
  const tokenUsage = {};
39
48
  if (st && st.tokenUsage && typeof st.tokenUsage.forEach === 'function') {
40
49
  st.tokenUsage.forEach((entry, ref) => {
@@ -45,14 +54,38 @@ export class StatePersistence {
45
54
  tokenUsage[ref] = { used: Math.floor(used), resetAt: Math.floor(resetAt) };
46
55
  });
47
56
  }
57
+ const costDays = {};
58
+ if (st?.costDays) {
59
+ const src = st.costDays instanceof Map ? Object.fromEntries(st.costDays) : st.costDays;
60
+ if (typeof src === 'object' && src !== null) Object.assign(costDays, src);
61
+ }
62
+ const costPerKey = {};
63
+ if (st?.costPerKey) {
64
+ const src = st.costPerKey instanceof Map ? Object.fromEntries(st.costPerKey) : st.costPerKey;
65
+ if (typeof src === 'object' && src !== null) Object.assign(costPerKey, src);
66
+ }
67
+ const usageDays = {};
68
+ if (st?.usageDays) {
69
+ const src = st.usageDays instanceof Map ? Object.fromEntries(st.usageDays) : st.usageDays;
70
+ if (typeof src === 'object' && src !== null) Object.assign(usageDays, src);
71
+ }
72
+ const usageCounts = {};
73
+ if (st?.usageCounts instanceof Map) {
74
+ st.usageCounts.forEach((cnt, ref) => {
75
+ if (Number.isFinite(cnt)) usageCounts[ref] = cnt;
76
+ });
77
+ }
48
78
  const outPool = {
49
79
  failedUntil,
50
80
  pointer: Number.isFinite(st?.pointer) ? st.pointer : 0,
51
81
  lastUsed: typeof st?.lastUsed === 'string' ? st.lastUsed : null,
52
82
  };
53
- if (Object.keys(tokenUsage).length > 0) {
54
- outPool.tokenUsage = tokenUsage;
55
- }
83
+ if (revokedRefs.length > 0) outPool.revokedRefs = revokedRefs;
84
+ if (Object.keys(tokenUsage).length > 0) outPool.tokenUsage = tokenUsage;
85
+ if (Object.keys(costDays).length > 0) outPool.costDays = costDays;
86
+ if (Object.keys(costPerKey).length > 0) outPool.costPerKey = costPerKey;
87
+ if (Object.keys(usageDays).length > 0) outPool.usageDays = usageDays;
88
+ if (Object.keys(usageCounts).length > 0) outPool.usageCounts = usageCounts;
56
89
  pools[base] = outPool;
57
90
  });
58
91
  }
@@ -109,7 +142,7 @@ export class StatePersistence {
109
142
  async load() {
110
143
  const raw = await safeReadJson(this.filePath, null);
111
144
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
112
- if (raw.version !== 1) return null;
145
+ if (raw.version !== 1 && raw.version !== 2) return null;
113
146
  return raw;
114
147
  }
115
148
 
@@ -126,7 +159,17 @@ export class StatePersistence {
126
159
  if (!existing || !hasPoolActivity(state)) {
127
160
  if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
128
161
  for (const [ref, until] of Object.entries(saved.failedUntil)) {
129
- if (Number.isFinite(until)) state.failedUntil.set(ref, until);
162
+ if (until === 'Infinity' || until === Infinity) {
163
+ state.failedUntil.set(ref, Infinity);
164
+ } else if (Number.isFinite(until)) {
165
+ state.failedUntil.set(ref, until);
166
+ }
167
+ }
168
+ }
169
+ if (Array.isArray(saved.revokedRefs)) {
170
+ if (!state.revokedRefs) state.revokedRefs = new Set();
171
+ for (const r of saved.revokedRefs) {
172
+ if (typeof r === 'string') state.revokedRefs.add(r);
130
173
  }
131
174
  }
132
175
  if (saved.tokenUsage && typeof saved.tokenUsage === 'object' && !Array.isArray(saved.tokenUsage)) {
@@ -139,6 +182,30 @@ export class StatePersistence {
139
182
  state.tokenUsage.set(ref, { used: Math.floor(used), resetAt: Math.floor(resetAt) });
140
183
  }
141
184
  }
185
+ if (saved.costDays && typeof saved.costDays === 'object') {
186
+ if (!state.costDays) state.costDays = new Map();
187
+ for (const [day, cost] of Object.entries(saved.costDays)) {
188
+ if (Number.isFinite(cost)) state.costDays.set(day, cost);
189
+ }
190
+ }
191
+ if (saved.costPerKey && typeof saved.costPerKey === 'object') {
192
+ if (!state.costPerKey) state.costPerKey = new Map();
193
+ for (const [k, c] of Object.entries(saved.costPerKey)) {
194
+ if (Number.isFinite(c)) state.costPerKey.set(k, c);
195
+ }
196
+ }
197
+ if (saved.usageDays && typeof saved.usageDays === 'object') {
198
+ if (!state.usageDays) state.usageDays = new Map();
199
+ for (const [d, u] of Object.entries(saved.usageDays)) {
200
+ if (Number.isFinite(u)) state.usageDays.set(d, u);
201
+ }
202
+ }
203
+ if (saved.usageCounts && typeof saved.usageCounts === 'object') {
204
+ if (!state.usageCounts) state.usageCounts = new Map();
205
+ for (const [k, cnt] of Object.entries(saved.usageCounts)) {
206
+ if (Number.isFinite(cnt)) state.usageCounts.set(k, cnt);
207
+ }
208
+ }
142
209
  state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
143
210
  state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
144
211
  }
@@ -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,
@@ -53,17 +54,30 @@ export function buildPoolItem({
53
54
  const exp = parseExpiry(expiresAt[indexed[i].index]);
54
55
  if (exp !== undefined) parsedExpiry[refs[i]] = exp;
55
56
  }
57
+ } else if (expiresAt && typeof expiresAt === 'object') {
58
+ for (let i = 0; i < refs.length; i++) {
59
+ const exp = parseExpiry(expiresAt[refs[i]]);
60
+ if (exp !== undefined) parsedExpiry[refs[i]] = exp;
61
+ }
56
62
  }
57
63
  const parsedPaused = new Set();
58
64
  if (Array.isArray(paused)) {
59
65
  for (let i = 0; i < refs.length; i++) {
60
- if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
66
+ if (paused[indexed[i].index] === true || paused.includes(refs[i])) parsedPaused.add(refs[i]);
67
+ }
68
+ } else if (paused instanceof Set) {
69
+ for (let i = 0; i < refs.length; i++) {
70
+ if (paused.has(refs[i])) parsedPaused.add(refs[i]);
61
71
  }
62
72
  }
63
73
  const parsedRevoked = new Set();
64
74
  if (Array.isArray(revoked)) {
65
75
  for (let i = 0; i < refs.length; i++) {
66
- if (revoked[indexed[i].index] === true) parsedRevoked.add(refs[i]);
76
+ if (revoked[indexed[i].index] === true || revoked.includes(refs[i])) parsedRevoked.add(refs[i]);
77
+ }
78
+ } else if (revoked instanceof Set) {
79
+ for (let i = 0; i < refs.length; i++) {
80
+ if (revoked.has(refs[i])) parsedRevoked.add(refs[i]);
67
81
  }
68
82
  }
69
83
  const weightsMap = {};
@@ -90,6 +104,7 @@ export function buildPoolItem({
90
104
  expiresAt: parsedExpiry,
91
105
  rpmLimit,
92
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,
93
108
  routingStrategy: poolStrategy,
94
109
  proactiveRateLimitGuard: poolGuard,
95
110
  };
@@ -98,6 +113,7 @@ export function buildPoolItem({
98
113
  export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
99
114
  const rpmLimit = cfg.rpmLimit ?? 0;
100
115
  const tpmLimit = cfg.tpmLimit ?? 0;
116
+ const concurrencyLimit = cfg.concurrencyLimit ?? 0;
101
117
  const quotaResetWindow = cfg.quotaResetWindow ?? null;
102
118
  const poolByRef = new Map();
103
119
  const providerToPool = new Map();
@@ -114,10 +130,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
114
130
  return initializePoolState(st);
115
131
  };
116
132
 
117
- 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) =>
118
134
  buildPoolItem({
119
135
  base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
120
- rpmLimit: pRpm, tpmLimit: pTpm, makeState, quotaResetWindow, ...meta,
136
+ rpmLimit: pRpm, tpmLimit: pTpm, concurrencyLimit: pConcurrency, makeState, quotaResetWindow, ...meta,
121
137
  });
122
138
 
123
139
  for (const p of cfg.providers ?? []) {
@@ -125,10 +141,11 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
125
141
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
126
142
  const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
127
143
  const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
144
+ const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
128
145
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
129
146
  provider: p.provider,
130
147
  model: null,
131
- }, provRpm, provTpm);
148
+ }, provRpm, provTpm, provConcurrency);
132
149
  if (pool) {
133
150
  for (const ref of pool.refs) {
134
151
  if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
@@ -140,12 +157,23 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
140
157
  for (const [model, mp] of Object.entries(p.models ?? {})) {
141
158
  const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
142
159
  const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
143
- const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, mp.revoked, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, {
160
+ const modelConcurrency = typeof mp.concurrencyLimit === 'number' ? mp.concurrencyLimit : provConcurrency;
161
+ const mergedPaused = new Set([
162
+ ...(pool?.pausedRefs ?? []),
163
+ ...(Array.isArray(mp.paused) ? mp.paused : (mp.paused instanceof Set ? [...mp.paused] : [])),
164
+ ]);
165
+ const mergedRevoked = new Set([
166
+ ...(pool?.revokedRefs ?? []),
167
+ ...(Array.isArray(mp.revoked) ? mp.revoked : (mp.revoked instanceof Set ? [...mp.revoked] : [])),
168
+ ]);
169
+ const modelExpiresAt = { ...(pool?.expiresAt ?? {}), ...(typeof mp.expiresAt === 'object' ? mp.expiresAt : {}) };
170
+ const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mergedPaused, mergedRevoked, poolCooldown, poolMax, modelExpiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
144
171
  provider: p.provider,
145
172
  model,
146
173
  quotas: mp.quotas,
147
- }, modelRpm, modelTpm);
174
+ }, modelRpm, modelTpm, modelConcurrency);
148
175
  if (!mpool) continue;
176
+ mpool.basePool = pool;
149
177
  byModel.set(model, mpool);
150
178
  for (const ref of mpool.refs) {
151
179
  if (!poolByRef.has(ref)) poolByRef.set(ref, mpool);
package/lib/pool.js CHANGED
@@ -316,6 +316,7 @@ export function isKeyPaused(pool, ref) {
316
316
  if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
317
317
  if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
318
318
  if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
319
+ if (pool.basePool) return isKeyPaused(pool.basePool, ref);
319
320
  return false;
320
321
  }
321
322
 
@@ -560,5 +561,6 @@ export function isKeyRevoked(pool, ref) {
560
561
  if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
561
562
  if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
562
563
  if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
564
+ if (pool.basePool) return isKeyRevoked(pool.basePool, ref);
563
565
  return false;
564
566
  }
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 {
@@ -22,12 +21,53 @@ import {
22
21
 
23
22
  /** Internal failure code for "local budgets forbid this dispatch". */
24
23
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
24
+ export const LOCAL_POOL_EXHAUSTED_CODE = 'LOCAL_POOL_EXHAUSTED';
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
+ }
25
65
 
26
66
  /**
27
67
  * @param {object} deps
28
68
  * @param {() => object} deps.buildRuntime current runtime snapshot
29
69
  * @param {() => object|undefined} deps.currentPool request-scoped pool (ALS)
30
- * @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
31
71
  * @param {object} [deps.latencyHistogram]
32
72
  * @param {object} [deps.concurrencyTracker]
33
73
  * @param {() => number} [deps.now] wall clock; injectable for tests
@@ -59,6 +99,22 @@ export function createResolver({
59
99
  }
60
100
 
61
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
+
62
118
  const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
63
119
  let list = pool.weightedRefs ?? pool.refs;
64
120
  if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
@@ -78,11 +134,9 @@ export function createResolver({
78
134
  if (isKeyPaused(pool, candidate)) continue;
79
135
  const until = pool.state.failedUntil.get(candidate);
80
136
  if (until !== undefined && until > at) continue;
81
- if (pool.expiresAt?.[candidate] !== undefined && at >= pool.expiresAt[candidate]) continue;
137
+ const candidateExp = pool.expiresAt?.[candidate] ?? pool.basePool?.expiresAt?.[candidate];
138
+ if (candidateExp !== undefined && at >= candidateExp) continue;
82
139
  // Local model token budget: a spent credential is simply not a candidate.
83
- // This is deliberately NOT recorded as a failure — the key is healthy and
84
- // merely out of budget — so it must never touch failedUntil / failCounts /
85
- // authFailCounts / brokenUntil.
86
140
  if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
87
141
  const rpmLimit = pool.rpmLimit ?? 0;
88
142
  if (rpmLimit > 0) {
@@ -117,20 +171,46 @@ export function createResolver({
117
171
  }
118
172
  }
119
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
+
120
190
  // Advance the shared cursor at selection time: concurrent requests must
121
191
  // not all pick the same slot.
122
192
  pool.state.pointer = (index + 1) % list.length;
123
- const hit = await original(candidate);
124
- if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
125
- settle(pool, candidate, at, onPicked);
126
- 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;
127
211
  }
128
- // No stored credential: fall back to the launching environment, which is
129
- // how env-bootstrapped pools work (issue #7).
130
- const envVal = envValue(candidate);
131
- if (envVal !== undefined) {
132
- settle(pool, candidate, at, onPicked);
133
- return { value: decryptSecret(envVal), source: 'env' };
212
+ if (acquired && typeof concurrencyTracker.release === 'function') {
213
+ concurrencyTracker.release(candidate, at);
134
214
  }
135
215
  }
136
216
 
@@ -144,16 +224,19 @@ export function createResolver({
144
224
  { code: LOCAL_QUOTA_CODE, localQuota: true },
145
225
  );
146
226
  }
147
- return original(ref);
227
+ throw Object.assign(
228
+ new Error(`dsh-key-rotation: all credentials in pool "${pool.base ?? pool.provider}" are currently blocked, paused, expired, or exhausted`),
229
+ { code: LOCAL_POOL_EXHAUSTED_CODE, localExhausted: true },
230
+ );
148
231
  };
149
232
  }
150
233
 
151
234
  /** Record a successful credential selection on the pool and request scope. */
152
- function settle(pool, candidate, now, onPicked) {
235
+ function settle(pool, candidate, now, onPicked, concurrencyRef) {
153
236
  pool.state.lastUsed = candidate;
154
237
  if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
155
238
  pool.state.lastUsedAt.set(candidate, now);
156
- onPicked?.(pool, candidate);
239
+ onPicked?.(pool, candidate, { concurrencyRef });
157
240
  pool.state.failCounts?.delete(candidate);
158
241
  pool.state.failedUntil.delete(candidate);
159
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();
@@ -210,9 +210,10 @@ export function createRotate(deps) {
210
210
  return llm.stream({ ...options, [MARKER]: true });
211
211
  });
212
212
  } catch (e) {
213
- if (e?.localQuota || e?.code === LOCAL_QUOTA_CODE) {
214
- lastFailure = finishError(LOCAL_QUOTA_CODE, String(e?.message ?? ''));
215
- logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget blocks dispatch — no upstream request sent`);
213
+ if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
214
+ const code = e?.code ?? (e?.localQuota ? LOCAL_QUOTA_CODE : LOCAL_POOL_EXHAUSTED_CODE);
215
+ lastFailure = finishError(code, String(e?.message ?? ''));
216
+ logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local rejection (${code}) — no upstream request sent`);
216
217
  break;
217
218
  }
218
219
  const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
@@ -224,7 +225,10 @@ export function createRotate(deps) {
224
225
  }
225
226
 
226
227
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
227
- 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
+ }
228
232
  try {
229
233
  try {
230
234
  for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
@@ -397,11 +401,12 @@ export function createRotate(deps) {
397
401
  }
398
402
  return; // clean end — served
399
403
  } finally {
400
- if (acquired && _pickedRef) {
401
- concurrencyTracker.release(_pickedRef);
402
- }
403
404
  }
404
405
  } finally {
406
+ if (reqStore.concurrencyRef) {
407
+ concurrencyTracker?.release(reqStore.concurrencyRef);
408
+ reqStore.concurrencyRef = null;
409
+ }
405
410
  // Also runs on consumer return(), abort, empty EOF and local errors.
406
411
  activePermit?.release();
407
412
  }
@@ -420,7 +425,7 @@ export function createRotate(deps) {
420
425
  const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
421
426
  if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
422
427
  const pools = runtime.providerToPool;
423
- const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
428
+ const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider);
424
429
  if (fb && fb.pool && fb.pool !== pool) {
425
430
  logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
426
431
  pool.state.lastReason = 'CASCADE';
package/lib/safe-fetch.js CHANGED
@@ -1,20 +1,57 @@
1
- // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354).
1
+ // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354, #411).
2
2
  import net from 'node:net';
3
3
  import dns from 'node:dns/promises';
4
+ import nodeDns from 'node:dns';
5
+ import { Agent } from 'undici';
4
6
 
5
7
  export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
6
8
  export const MAX_REDIRECTS = 5;
7
9
 
10
+ /**
11
+ * Parses an IPv6 string into an array of 8 16-bit integers (words).
12
+ * Supports standard hex, compressed `::`, and embedded dotted IPv4 (e.g. `::ffff:127.0.0.1`).
13
+ * @param {string} ip
14
+ * @returns {number[]|null}
15
+ */
16
+ export function parseIpv6Words(ip) {
17
+ if (typeof ip !== 'string') return null;
18
+ let s = ip.toLowerCase().trim().replace(/^\[|\]$/g, '');
19
+ const lastColon = s.lastIndexOf(':');
20
+ if (lastColon !== -1) {
21
+ const tail = s.slice(lastColon + 1);
22
+ if (net.isIP(tail) === 4) {
23
+ const parts = tail.split('.').map(Number);
24
+ if (parts.length === 4 && !parts.some(n => isNaN(n) || n < 0 || n > 255)) {
25
+ const w6 = ((parts[0] << 8) | parts[1]) & 0xffff;
26
+ const w7 = ((parts[2] << 8) | parts[3]) & 0xffff;
27
+ s = s.slice(0, lastColon) + ':' + w6.toString(16) + ':' + w7.toString(16);
28
+ }
29
+ }
30
+ }
31
+ const halves = s.split('::');
32
+ if (halves.length > 2) return null;
33
+ const left = halves[0] ? halves[0].split(':').map(x => parseInt(x, 16)) : [];
34
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':').map(x => parseInt(x, 16)) : [];
35
+ if (left.some(isNaN) || right.some(isNaN)) return null;
36
+ const missing = 8 - (left.length + right.length);
37
+ if (halves.length === 1 && missing !== 0) return null;
38
+ if (missing < 0) return null;
39
+ const middle = new Array(missing).fill(0);
40
+ return [...left, ...middle, ...right];
41
+ }
42
+
8
43
  /**
9
44
  * Checks if an IPv4 or IPv6 address is private, loopback, link-local, multicast, or reserved.
45
+ * Canonicalizes IPv6 addresses including hex and dotted-quad IPv4-mapped forms (#411).
10
46
  * @param {string} ip
11
47
  * @returns {boolean} true if private/reserved/loopback
12
48
  */
13
49
  export function isPrivateOrReservedIp(ip) {
14
50
  if (!ip || typeof ip !== 'string') return true;
15
- const kind = net.isIP(ip);
51
+ const cleaned = ip.trim().replace(/^\[|\]$/g, '');
52
+ const kind = net.isIP(cleaned);
16
53
  if (kind === 4) {
17
- const parts = ip.split('.').map(Number);
54
+ const parts = cleaned.split('.').map(Number);
18
55
  if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return true;
19
56
  const [a, b, c, d] = parts;
20
57
  if (a === 0) return true; // 0.0.0.0/8 current network
@@ -33,22 +70,70 @@ export function isPrivateOrReservedIp(ip) {
33
70
  return false;
34
71
  }
35
72
  if (kind === 6) {
36
- const norm = ip.toLowerCase();
37
- if (norm === '::' || norm === '::1' || norm === '0:0:0:0:0:0:0:0' || norm === '0:0:0:0:0:0:0:1') return true;
38
- if (norm.startsWith('::ffff:') || norm.startsWith('0:0:0:0:0:ffff:')) {
39
- const last = norm.split(':').pop();
40
- if (net.isIP(last) === 4) return isPrivateOrReservedIp(last);
73
+ const words = parseIpv6Words(cleaned);
74
+ if (!words || words.length !== 8) return true;
75
+ const [w0, w1, w2, w3, w4, w5, w6, w7] = words;
76
+ // Unspecified :: or loopback ::1
77
+ if (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0 && w6 === 0) {
78
+ if (w7 === 0 || w7 === 1) return true;
79
+ }
80
+ // IPv4-mapped (::ffff:0:0/96), IPv4-compatible (::/96), or NAT64 (64:ff9b::/96)
81
+ const isMapped = (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0xffff);
82
+ const isCompat = (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0);
83
+ const isNat64 = (w0 === 0x0064 && w1 === 0xff9b && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0);
84
+ if (isMapped || isCompat || isNat64) {
85
+ const a = (w6 >> 8) & 0xff;
86
+ const b = w6 & 0xff;
87
+ const c = (w7 >> 8) & 0xff;
88
+ const d = w7 & 0xff;
89
+ return isPrivateOrReservedIp(`${a}.${b}.${c}.${d}`);
41
90
  }
42
- if (/^fe[89ab][0-9a-f]:/i.test(norm)) return true; // fe80::/10 link-local
43
- if (/^f[cd][0-9a-f]{2}:/i.test(norm)) return true; // fc00::/7 unique local
44
- if (/^ff[0-9a-f]{2}:/i.test(norm)) return true; // ff00::/8 multicast
45
- if (/^(?:00)?2001:(?:0)?db8:/i.test(norm)) return true; // 2001:db8::/32 documentation
46
- if (/^(?:00)?100:/i.test(norm)) return true; // 100::/64 discard
91
+ if ((w0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
92
+ if ((w0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
93
+ if ((w0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast
94
+ if (w0 === 0x2001 && w1 === 0x0db8) return true; // 2001:db8::/32 documentation
95
+ if (w0 === 0x0100 && w1 === 0) return true; // 100::/64 discard
47
96
  return false;
48
97
  }
49
98
  return true;
50
99
  }
51
100
 
101
+ /**
102
+ * Creates an undici Agent with connect-time lookup validation against DNS rebinding / TOCTOU (#411).
103
+ * @param {object} [opts]
104
+ * @param {Function} [opts.lookupImpl]
105
+ * @returns {Agent}
106
+ */
107
+ export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
108
+ return new Agent({
109
+ connect: {
110
+ lookup: (hostname, options, callback) => {
111
+ const cb = typeof options === 'function' ? options : callback;
112
+ const opts = typeof options === 'function' ? {} : options;
113
+ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
114
+ if (err) return cb(err);
115
+ const list = Array.isArray(addresses) ? addresses : [addresses];
116
+ for (const item of list) {
117
+ const addr = typeof item === 'string' ? item : item.address;
118
+ if (isPrivateOrReservedIp(addr)) {
119
+ const ssrfErr = new Error(`dsh-key-rotation: connect blocked: host ${hostname} resolves to private or reserved IP ${addr}`);
120
+ ssrfErr.code = 'SSRF_BLOCKED';
121
+ return cb(ssrfErr);
122
+ }
123
+ }
124
+ if (typeof options === 'function') {
125
+ const first = list[0];
126
+ const addr = typeof first === 'string' ? first : first.address;
127
+ const fam = typeof first === 'object' && first.family ? first.family : (net.isIP(addr) || 4);
128
+ return cb(null, addr, fam);
129
+ }
130
+ return cb(null, addresses);
131
+ });
132
+ },
133
+ },
134
+ });
135
+ }
136
+
52
137
  /**
53
138
  * Validates a target URL and its hostname/IP against SSRF.
54
139
  * @param {string} urlString
@@ -173,7 +258,7 @@ export async function readBoundedJson(resp, maxBytes = MAX_IMPORT_BYTES) {
173
258
  }
174
259
 
175
260
  /**
176
- * Fetches JSON from a URL with SSRF protection, redirect verification, and size bounding.
261
+ * Fetches JSON from a URL with SSRF protection, connect-time rebinding guard, redirect verification, and size bounding.
177
262
  * @param {string} url
178
263
  * @param {object} options
179
264
  */
@@ -183,14 +268,18 @@ export async function safeFetchJson(url, {
183
268
  lookupImpl = dns.lookup,
184
269
  maxBytes = MAX_IMPORT_BYTES,
185
270
  redirectsLeft = MAX_REDIRECTS,
271
+ dispatcher,
186
272
  } = {}) {
273
+ const safeDispatcher = dispatcher ?? (fetchImpl === globalThis.fetch ? createSafeDispatcher() : undefined);
187
274
  let currentUrl = url;
188
275
  while (true) {
189
276
  await assertSafeUrl(currentUrl, { lookupImpl });
190
- const resp = await fetchImpl(currentUrl, {
277
+ const fetchOpts = {
191
278
  signal,
192
279
  redirect: 'manual',
193
- });
280
+ };
281
+ if (safeDispatcher) fetchOpts.dispatcher = safeDispatcher;
282
+ const resp = await fetchImpl(currentUrl, fetchOpts);
194
283
 
195
284
  if ([301, 302, 303, 307, 308].includes(resp.status)) {
196
285
  if (redirectsLeft <= 0) {
@@ -218,4 +307,4 @@ export async function safeFetchJson(url, {
218
307
 
219
308
  return await readBoundedJson(resp, maxBytes);
220
309
  }
221
- }
310
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.38",
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": [
@@ -57,8 +57,8 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/schemastery": "^3.18.4",
61
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1"
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "scripts": {
64
64
  "test": "node tools/test.mjs",
@@ -70,5 +70,8 @@
70
70
  "devDependencies": {
71
71
  "react": "18.3.1",
72
72
  "react-test-renderer": "18.3.1"
73
+ },
74
+ "dependencies": {
75
+ "undici": "^6.29.0"
73
76
  }
74
77
  }