@goodandready/dsh-key-rotation 0.8.39 → 0.8.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/lib/budget-monitor.js +36 -32
- package/lib/cascade.js +19 -6
- package/lib/concurrency.js +17 -6
- package/lib/index.js +6 -2
- package/lib/pool-builder.js +9 -4
- package/lib/resolver.js +96 -18
- package/lib/rotate.js +11 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.40 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Dynamic concurrency limits & least-loaded connection tracking (#407)**:
|
|
7
|
+
- Added `configure({ limit, staleMs })` to `ConcurrencyTracker` so runtime limit updates from configuration or settings take immediate effect.
|
|
8
|
+
- Track in-flight request counts across all keys regardless of whether global limit is 0, ensuring accurate `least-loaded` routing metrics and status telemetry.
|
|
9
|
+
- Atomically acquire in-flight permit at credential selection time in `lib/resolver.js` and roll back immediately if the credential resolver rejects.
|
|
10
|
+
- Safely release concurrency permits in the outer `finally` block of `lib/rotate.js` on stream completion, client abort, error, or unhandled rejection.
|
|
11
|
+
- Skip saturated keys in `pickLeastLoaded` and fail closed when all candidate keys exceed their configured concurrency limits.
|
|
12
|
+
- **Model-aware cross-provider cascade failover (#414)**:
|
|
13
|
+
- `pickCascadeFallback` now maps fallback models to target model sub-pools (`modelPoolByProvider`) rather than falling back only to base provider pools.
|
|
14
|
+
- Supported model-only providers (providers where credentials are defined exclusively under `models`) as valid cascade fallback targets.
|
|
15
|
+
- Validated local model token budgets (`isModelQuotaAvailable`) during cascade selection to bypass exhausted model pools.
|
|
16
|
+
- **Aggregated provider monetary budget & model pool enforcement (#422)**:
|
|
17
|
+
- Added `getProviderCost(provider, runtime, now)` to aggregate daily and weekly spend across provider base pools and all model sub-pools without double-counting shared state.
|
|
18
|
+
- `checkBudgetAndHealthAlerts` now consolidates provider spend across all pools, sends unified webhook alerts with correct provider names, and pauses all pools (base and model sub-pools) for 24h when `pauseOnBudget: true`.
|
|
19
|
+
- Enforced monetary budget refusal (`LOCAL_POOL_EXHAUSTED`) at credential resolution time in `lib/resolver.js` when provider budget is exceeded and `pauseOnBudget` is active.
|
|
20
|
+
|
|
3
21
|
## 0.8.39 - 2026-09-30
|
|
4
22
|
|
|
5
23
|
### Fixed
|
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/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/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,
|
|
@@ -103,6 +104,7 @@ export function buildPoolItem({
|
|
|
103
104
|
expiresAt: parsedExpiry,
|
|
104
105
|
rpmLimit,
|
|
105
106
|
tpmLimit: typeof tpmLimit === 'number' && Number.isFinite(tpmLimit) && tpmLimit > 0 ? Math.floor(tpmLimit) : 0,
|
|
107
|
+
concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
|
|
106
108
|
routingStrategy: poolStrategy,
|
|
107
109
|
proactiveRateLimitGuard: poolGuard,
|
|
108
110
|
};
|
|
@@ -111,6 +113,7 @@ export function buildPoolItem({
|
|
|
111
113
|
export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
|
|
112
114
|
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
113
115
|
const tpmLimit = cfg.tpmLimit ?? 0;
|
|
116
|
+
const concurrencyLimit = cfg.concurrencyLimit ?? 0;
|
|
114
117
|
const quotaResetWindow = cfg.quotaResetWindow ?? null;
|
|
115
118
|
const poolByRef = new Map();
|
|
116
119
|
const providerToPool = new Map();
|
|
@@ -127,10 +130,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
127
130
|
return initializePoolState(st);
|
|
128
131
|
};
|
|
129
132
|
|
|
130
|
-
const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit) =>
|
|
133
|
+
const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit, pConcurrency = concurrencyLimit) =>
|
|
131
134
|
buildPoolItem({
|
|
132
135
|
base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
|
|
133
|
-
rpmLimit: pRpm, tpmLimit: pTpm, makeState, quotaResetWindow, ...meta,
|
|
136
|
+
rpmLimit: pRpm, tpmLimit: pTpm, concurrencyLimit: pConcurrency, makeState, quotaResetWindow, ...meta,
|
|
134
137
|
});
|
|
135
138
|
|
|
136
139
|
for (const p of cfg.providers ?? []) {
|
|
@@ -138,10 +141,11 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
138
141
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
139
142
|
const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
|
|
140
143
|
const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
|
|
144
|
+
const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
|
|
141
145
|
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
142
146
|
provider: p.provider,
|
|
143
147
|
model: null,
|
|
144
|
-
}, provRpm, provTpm);
|
|
148
|
+
}, provRpm, provTpm, provConcurrency);
|
|
145
149
|
if (pool) {
|
|
146
150
|
for (const ref of pool.refs) {
|
|
147
151
|
if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
|
|
@@ -153,6 +157,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
153
157
|
for (const [model, mp] of Object.entries(p.models ?? {})) {
|
|
154
158
|
const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
|
|
155
159
|
const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
|
|
160
|
+
const modelConcurrency = typeof mp.concurrencyLimit === 'number' ? mp.concurrencyLimit : provConcurrency;
|
|
156
161
|
const mergedPaused = new Set([
|
|
157
162
|
...(pool?.pausedRefs ?? []),
|
|
158
163
|
...(Array.isArray(mp.paused) ? mp.paused : (mp.paused instanceof Set ? [...mp.paused] : [])),
|
|
@@ -166,7 +171,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
166
171
|
provider: p.provider,
|
|
167
172
|
model,
|
|
168
173
|
quotas: mp.quotas,
|
|
169
|
-
}, modelRpm, modelTpm);
|
|
174
|
+
}, modelRpm, modelTpm, modelConcurrency);
|
|
170
175
|
if (!mpool) continue;
|
|
171
176
|
mpool.basePool = pool;
|
|
172
177
|
byModel.set(model, mpool);
|
package/lib/resolver.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// lib/resolver.js — the credential selector behind credentials.resolve (#381).
|
|
2
1
|
//
|
|
3
2
|
// Extracted from lib/index.js so the routing decisions that this feature
|
|
4
3
|
// depends on — request-scoped pool ownership, model token quota enforcement,
|
|
@@ -10,7 +9,7 @@
|
|
|
10
9
|
// delegates to `original(ref)`.
|
|
11
10
|
|
|
12
11
|
import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
|
|
13
|
-
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
12
|
+
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
14
13
|
import { decryptSecret } from './crypto-storage.js';
|
|
15
14
|
import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
|
|
16
15
|
import {
|
|
@@ -24,11 +23,51 @@ import {
|
|
|
24
23
|
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
25
24
|
export const LOCAL_POOL_EXHAUSTED_CODE = 'LOCAL_POOL_EXHAUSTED';
|
|
26
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Calculates aggregated daily and weekly spend across base pool and model pools for a provider without double-counting (#422).
|
|
28
|
+
*/
|
|
29
|
+
export function getProviderCost(provider, runtime, now = Date.now()) {
|
|
30
|
+
const seenStates = new Set();
|
|
31
|
+
let daily = 0;
|
|
32
|
+
let weekly = 0;
|
|
33
|
+
const pools = [];
|
|
34
|
+
const basePool = runtime?.providerToPool?.get?.(provider);
|
|
35
|
+
if (basePool) pools.push(basePool);
|
|
36
|
+
const modelPools = runtime?.modelPoolByProvider?.get?.(provider);
|
|
37
|
+
if (modelPools) {
|
|
38
|
+
for (const mp of modelPools.values()) {
|
|
39
|
+
if (!pools.includes(mp)) pools.push(mp);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (runtime?.index?.allPools) {
|
|
43
|
+
for (const p of runtime.index.allPools) {
|
|
44
|
+
if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
|
|
45
|
+
pools.push(p);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (runtime?.poolByRef) {
|
|
50
|
+
for (const p of runtime.poolByRef.values()) {
|
|
51
|
+
if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
|
|
52
|
+
pools.push(p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const p of pools) {
|
|
57
|
+
if (p.state && !seenStates.has(p.state)) {
|
|
58
|
+
seenStates.add(p.state);
|
|
59
|
+
daily += costForDay(p.state.costDays);
|
|
60
|
+
weekly += costForWeek(p.state.costDays, now);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { daily, weekly, pools };
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
/**
|
|
28
67
|
* @param {object} deps
|
|
29
68
|
* @param {() => object} deps.buildRuntime current runtime snapshot
|
|
30
69
|
* @param {() => object|undefined} deps.currentPool request-scoped pool (ALS)
|
|
31
|
-
* @param {(pool: object, candidate: string) => void} [deps.onPicked] picked-ref hook
|
|
70
|
+
* @param {(pool: object, candidate: string, meta?: object) => void} [deps.onPicked] picked-ref hook
|
|
32
71
|
* @param {object} [deps.latencyHistogram]
|
|
33
72
|
* @param {object} [deps.concurrencyTracker]
|
|
34
73
|
* @param {() => number} [deps.now] wall clock; injectable for tests
|
|
@@ -60,6 +99,22 @@ export function createResolver({
|
|
|
60
99
|
}
|
|
61
100
|
|
|
62
101
|
const at = now();
|
|
102
|
+
|
|
103
|
+
// Provider monetary budget refusal (#422):
|
|
104
|
+
const providerName = pool.provider ?? pool.base?.split?.('::')?.[0];
|
|
105
|
+
const budget = runtime.providerBudgets?.get?.(providerName);
|
|
106
|
+
if (budget && budget.pauseOnBudget) {
|
|
107
|
+
const { daily, weekly } = getProviderCost(providerName, runtime, at);
|
|
108
|
+
const vDaily = budgetVerdict(daily, budget.costBudgetDaily);
|
|
109
|
+
const vWeekly = budgetVerdict(weekly, budget.costBudgetWeekly);
|
|
110
|
+
if (vDaily.exceeded || vWeekly.exceeded) {
|
|
111
|
+
throw Object.assign(
|
|
112
|
+
new Error(`dsh-key-rotation: provider "${providerName}" cost budget exceeded (daily: $${daily.toFixed(2)}/${budget.costBudgetDaily}, weekly: $${weekly.toFixed(2)}/${budget.costBudgetWeekly})`),
|
|
113
|
+
{ code: LOCAL_POOL_EXHAUSTED_CODE, localExhausted: true },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
64
119
|
let list = pool.weightedRefs ?? pool.refs;
|
|
65
120
|
if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
|
|
@@ -82,9 +137,6 @@ export function createResolver({
|
|
|
82
137
|
const candidateExp = pool.expiresAt?.[candidate] ?? pool.basePool?.expiresAt?.[candidate];
|
|
83
138
|
if (candidateExp !== undefined && at >= candidateExp) continue;
|
|
84
139
|
// Local model token budget: a spent credential is simply not a candidate.
|
|
85
|
-
// This is deliberately NOT recorded as a failure — the key is healthy and
|
|
86
|
-
// merely out of budget — so it must never touch failedUntil / failCounts /
|
|
87
|
-
// authFailCounts / brokenUntil.
|
|
88
140
|
if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
|
|
89
141
|
const rpmLimit = pool.rpmLimit ?? 0;
|
|
90
142
|
if (rpmLimit > 0) {
|
|
@@ -119,20 +171,46 @@ export function createResolver({
|
|
|
119
171
|
}
|
|
120
172
|
}
|
|
121
173
|
|
|
174
|
+
// Concurrency limit per-key check (#407):
|
|
175
|
+
const concurrencyLimit = pool.concurrencyLimit ?? runtime.concurrencyLimit ?? 0;
|
|
176
|
+
if (concurrencyLimit > 0 && concurrencyTracker && typeof concurrencyTracker.getActive === 'function') {
|
|
177
|
+
if (concurrencyTracker.getActive(candidate, at) >= concurrencyLimit) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Atomically acquire concurrency permit upon candidate selection (#407):
|
|
183
|
+
let acquired = false;
|
|
184
|
+
if (concurrencyTracker && typeof concurrencyTracker.acquire === 'function') {
|
|
185
|
+
const ok = concurrencyTracker.acquire(candidate, at, concurrencyLimit);
|
|
186
|
+
if (!ok) continue;
|
|
187
|
+
acquired = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
// Advance the shared cursor at selection time: concurrent requests must
|
|
123
191
|
// not all pick the same slot.
|
|
124
192
|
pool.state.pointer = (index + 1) % list.length;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
193
|
+
try {
|
|
194
|
+
const hit = await original(candidate);
|
|
195
|
+
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
196
|
+
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
|
|
197
|
+
return { ...hit, value: decryptSecret(hit.value) };
|
|
198
|
+
}
|
|
199
|
+
// No stored credential: fall back to the launching environment, which is
|
|
200
|
+
// how env-bootstrapped pools work (issue #7).
|
|
201
|
+
const envVal = envValue(candidate);
|
|
202
|
+
if (envVal !== undefined) {
|
|
203
|
+
settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
|
|
204
|
+
return { value: decryptSecret(envVal), source: 'env' };
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (acquired && typeof concurrencyTracker.release === 'function') {
|
|
208
|
+
concurrencyTracker.release(candidate, at);
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
129
211
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const envVal = envValue(candidate);
|
|
133
|
-
if (envVal !== undefined) {
|
|
134
|
-
settle(pool, candidate, at, onPicked);
|
|
135
|
-
return { value: decryptSecret(envVal), source: 'env' };
|
|
212
|
+
if (acquired && typeof concurrencyTracker.release === 'function') {
|
|
213
|
+
concurrencyTracker.release(candidate, at);
|
|
136
214
|
}
|
|
137
215
|
}
|
|
138
216
|
|
|
@@ -154,11 +232,11 @@ export function createResolver({
|
|
|
154
232
|
}
|
|
155
233
|
|
|
156
234
|
/** Record a successful credential selection on the pool and request scope. */
|
|
157
|
-
function settle(pool, candidate, now, onPicked) {
|
|
235
|
+
function settle(pool, candidate, now, onPicked, concurrencyRef) {
|
|
158
236
|
pool.state.lastUsed = candidate;
|
|
159
237
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
160
238
|
pool.state.lastUsedAt.set(candidate, now);
|
|
161
|
-
onPicked?.(pool, candidate);
|
|
239
|
+
onPicked?.(pool, candidate, { concurrencyRef });
|
|
162
240
|
pool.state.failCounts?.delete(candidate);
|
|
163
241
|
pool.state.failedUntil.delete(candidate);
|
|
164
242
|
pool.state.authFailCounts?.delete(candidate);
|
package/lib/rotate.js
CHANGED
|
@@ -80,7 +80,7 @@ export function createRotate(deps) {
|
|
|
80
80
|
let lastFailure = null;
|
|
81
81
|
const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
|
|
82
82
|
let activePermit = null;
|
|
83
|
-
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
83
|
+
const reqStore = { pool, pickedRef: undefined, concurrencyRef: undefined, startMs: now() };
|
|
84
84
|
setRotateStartMs(reqStore.startMs);
|
|
85
85
|
const wallNow = Date.now();
|
|
86
86
|
const quotaManaged = hasModelQuotaConfig(pool);
|
|
@@ -97,7 +97,7 @@ export function createRotate(deps) {
|
|
|
97
97
|
const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
98
98
|
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
99
99
|
if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
|
|
100
|
-
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model);
|
|
100
|
+
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model, runtimeQ.modelPoolByProvider);
|
|
101
101
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
102
102
|
pool.state.lastReason = 'CASCADE';
|
|
103
103
|
pool.state.lastSwitchAt = now();
|
|
@@ -225,7 +225,10 @@ export function createRotate(deps) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
228
|
-
|
|
228
|
+
if (_pickedRef && !reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
|
|
229
|
+
const ok = concurrencyTracker.acquire(_pickedRef, now(), pool.concurrencyLimit ?? runtime0.concurrencyLimit ?? 0);
|
|
230
|
+
if (ok) reqStore.concurrencyRef = _pickedRef;
|
|
231
|
+
}
|
|
229
232
|
try {
|
|
230
233
|
try {
|
|
231
234
|
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
@@ -398,11 +401,12 @@ export function createRotate(deps) {
|
|
|
398
401
|
}
|
|
399
402
|
return; // clean end — served
|
|
400
403
|
} finally {
|
|
401
|
-
if (acquired && _pickedRef) {
|
|
402
|
-
concurrencyTracker.release(_pickedRef);
|
|
403
|
-
}
|
|
404
404
|
}
|
|
405
405
|
} finally {
|
|
406
|
+
if (reqStore.concurrencyRef) {
|
|
407
|
+
concurrencyTracker?.release(reqStore.concurrencyRef);
|
|
408
|
+
reqStore.concurrencyRef = null;
|
|
409
|
+
}
|
|
406
410
|
// Also runs on consumer return(), abort, empty EOF and local errors.
|
|
407
411
|
activePermit?.release();
|
|
408
412
|
}
|
|
@@ -421,7 +425,7 @@ export function createRotate(deps) {
|
|
|
421
425
|
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
422
426
|
if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
423
427
|
const pools = runtime.providerToPool;
|
|
424
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
|
|
428
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider);
|
|
425
429
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
426
430
|
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
|
|
427
431
|
pool.state.lastReason = 'CASCADE';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
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": [
|