@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 +35 -0
- package/README.md +3 -1
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/budget-monitor.js +36 -32
- package/lib/cascade.js +19 -6
- package/lib/client.js +8 -7
- package/lib/concurrency.js +17 -6
- package/lib/index.js +6 -2
- package/lib/persistence.js +72 -5
- package/lib/pool-builder.js +35 -7
- package/lib/pool.js +2 -0
- package/lib/resolver.js +103 -20
- package/lib/rotate.js +15 -10
- package/lib/safe-fetch.js +106 -17
- package/package.json +6 -3
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
|
-
* **
|
|
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
|
-
* **
|
|
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
|
-
*
|
|
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
|
|
package/lib/budget-monitor.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
47
|
-
logger?.warn?.(`[dsh-key-rotation] ${
|
|
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: ${
|
|
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
|
|
35
|
+
provider,
|
|
55
36
|
kind: 'budget',
|
|
56
37
|
spend: { daily, weekly },
|
|
57
38
|
actionToken: token || undefined,
|
|
58
39
|
actions: token ? [
|
|
59
|
-
{ id: `pause-${
|
|
60
|
-
{ id: `reset-${
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1695
|
-
|
|
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 () => {
|
package/lib/concurrency.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// concurrency.js — per-key in-flight counter + least-connections picking (
|
|
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
|
-
|
|
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 >=
|
|
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 (
|
|
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)
|
|
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,
|
package/lib/persistence.js
CHANGED
|
@@ -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 (
|
|
54
|
-
|
|
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 (
|
|
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
|
}
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
51
|
+
const cleaned = ip.trim().replace(/^\[|\]$/g, '');
|
|
52
|
+
const kind = net.isIP(cleaned);
|
|
16
53
|
if (kind === 4) {
|
|
17
|
-
const parts =
|
|
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
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 (
|
|
43
|
-
if (
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
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
|
|
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.
|
|
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/
|
|
61
|
-
"@deepseek-ai/
|
|
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
|
}
|