@goodandready/dsh-key-rotation 0.8.39 → 0.8.41
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 +37 -0
- package/lib/budget-monitor.js +36 -32
- package/lib/cascade.js +24 -6
- package/lib/concurrency.js +17 -6
- package/lib/index.js +6 -2
- package/lib/lifecycle.js +3 -1
- package/lib/ops-keys.js +35 -13
- package/lib/ops-test.js +5 -2
- package/lib/ops-webhook.js +38 -12
- package/lib/pool-builder.js +9 -4
- package/lib/resolver.js +96 -22
- package/lib/rotate.js +35 -8
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.41 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Reproducible CI and core compatibility matrix (#424)**:
|
|
7
|
+
- Added explicit test `devDependencies` (`@deepseek-ai/schemastery`, `@deepseek-ai/cordis`, `@deepseek-ai/dsh-llm`, `semver`) to `package.json` to ensure clean checkouts (`npm ci`) reproduce all unit tests without depending on global or ambient node_modules.
|
|
8
|
+
- Updated modern core detection in `test/support/real-core.mjs` to properly identify DSH `0.2.0-rc.1`, `0.2.0-rc.2`, and future releases as modern loaders rather than misclassifying them as legacy.
|
|
9
|
+
- **Preserved exponential backoff across credential selection (#410)**:
|
|
10
|
+
- Removed premature deletion of `failCounts`, `authFailCounts`, and `brokenUntil` during resolver selection in `lib/resolver.js` (`settle()`).
|
|
11
|
+
- Quarantine backoff state is now strictly retained until upstream requests finish successfully via `recordSuccess()`, preventing flapping keys from resetting to first-level retry backoff on every dispatch.
|
|
12
|
+
- **Cascade failover on open circuit breaker (#413)**:
|
|
13
|
+
- When a primary provider's circuit breaker is open (`breaker.acquire()` returns null), `lib/rotate.js` now evaluates `pickCascadeFallback` before terminating, seamlessly routing traffic to healthy fallback providers.
|
|
14
|
+
- `pickCascadeFallback` in `lib/cascade.js` now validates circuit breaker state, skipping fallback providers whose circuits are open and failing closed with `CIRCUIT_OPEN` only when all cascade options are exhausted.
|
|
15
|
+
- **Plaintext secret decryption for diagnostic probes and self-healing (#415)**:
|
|
16
|
+
- Added `decryptSecret` decryption to `lib/ops-test.js` (`/dsh-key-rotation/test` route) and `lib/lifecycle.js` (`autoUnbreakBrokenKeys`), preventing encrypted ciphertext credentials (`enc:v1:...`) from being sent to upstream LLM probe endpoints.
|
|
17
|
+
- Probe success now cleanses `revokedRefs` alongside `failedUntil` and `failCounts`.
|
|
18
|
+
- **Fan-out reset across all model pools and shared credentials (#416)**:
|
|
19
|
+
- Key reset (`POST /dsh-key-rotation/reset` with `ref`) now fans out across ALL pools in `poolState` containing the ref rather than halting after the first match, clearing `failedUntil`, `failCounts`, `authFailCounts`, `brokenUntil`, `tokenUsage`, and `revokedRefs`.
|
|
20
|
+
- Provider reset (`provider`) and webhook actions (`pause-*`, `reset-*`) now fan out across base pools and all model sub-pools (`provider::*`), resetting circuit breakers and all associated quotas.
|
|
21
|
+
|
|
22
|
+
## 0.8.40 - 2026-09-30
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Dynamic concurrency limits & least-loaded connection tracking (#407)**:
|
|
26
|
+
- Added `configure({ limit, staleMs })` to `ConcurrencyTracker` so runtime limit updates from configuration or settings take immediate effect.
|
|
27
|
+
- Track in-flight request counts across all keys regardless of whether global limit is 0, ensuring accurate `least-loaded` routing metrics and status telemetry.
|
|
28
|
+
- Atomically acquire in-flight permit at credential selection time in `lib/resolver.js` and roll back immediately if the credential resolver rejects.
|
|
29
|
+
- Safely release concurrency permits in the outer `finally` block of `lib/rotate.js` on stream completion, client abort, error, or unhandled rejection.
|
|
30
|
+
- Skip saturated keys in `pickLeastLoaded` and fail closed when all candidate keys exceed their configured concurrency limits.
|
|
31
|
+
- **Model-aware cross-provider cascade failover (#414)**:
|
|
32
|
+
- `pickCascadeFallback` now maps fallback models to target model sub-pools (`modelPoolByProvider`) rather than falling back only to base provider pools.
|
|
33
|
+
- Supported model-only providers (providers where credentials are defined exclusively under `models`) as valid cascade fallback targets.
|
|
34
|
+
- Validated local model token budgets (`isModelQuotaAvailable`) during cascade selection to bypass exhausted model pools.
|
|
35
|
+
- **Aggregated provider monetary budget & model pool enforcement (#422)**:
|
|
36
|
+
- 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.
|
|
37
|
+
- `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`.
|
|
38
|
+
- Enforced monetary budget refusal (`LOCAL_POOL_EXHAUSTED`) at credential resolution time in `lib/resolver.js` when provider budget is exceeded and `pauseOnBudget` is active.
|
|
39
|
+
|
|
3
40
|
## 0.8.39 - 2026-09-30
|
|
4
41
|
|
|
5
42
|
### 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,46 @@
|
|
|
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, circuitBreaker) {
|
|
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 breaker = circuitBreaker || cfg?.circuitBreaker || null;
|
|
18
|
+
if (breaker && typeof breaker.canRequest === 'function' && !breaker.canRequest(fb.provider)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
|
|
23
|
+
let pool = null;
|
|
24
|
+
if (modelPools && mappedModel) {
|
|
25
|
+
pool = selectPool(modelPools, provPools, fb.provider, mappedModel);
|
|
26
|
+
}
|
|
27
|
+
if (!pool && provPools) {
|
|
28
|
+
pool = provPools instanceof Map ? provPools.get(fb.provider) : (provPools ? provPools[fb.provider] : null);
|
|
29
|
+
}
|
|
13
30
|
if (!pool) continue;
|
|
31
|
+
|
|
14
32
|
const now = Date.now();
|
|
15
33
|
let healthy = 0;
|
|
16
34
|
for (const ref of pool.refs) {
|
|
17
35
|
if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
|
|
18
36
|
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
19
37
|
if (failedUntil > now) continue;
|
|
20
|
-
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
38
|
+
const exp = pool.expiresAt ? pool.expiresAt[ref] : (pool.basePool?.expiresAt ? pool.basePool.expiresAt[ref] : undefined);
|
|
21
39
|
if (exp !== undefined && now >= exp) continue;
|
|
40
|
+
if (hasModelQuotaConfig(pool) && !isModelQuotaAvailable(pool, ref, now)) continue;
|
|
22
41
|
healthy += 1;
|
|
23
42
|
}
|
|
24
43
|
if (healthy === 0) continue;
|
|
25
|
-
const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
|
|
26
44
|
return { provider: fb.provider, pool, model: mappedModel };
|
|
27
45
|
}
|
|
28
46
|
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/lifecycle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// lib/lifecycle.js — background timers and lifecycle effects
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
3
4
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { envValue } from './pool.js';
|
|
@@ -45,8 +46,9 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
45
46
|
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
46
47
|
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
47
48
|
const hit = await resolve?.call(credentials, ref);
|
|
48
|
-
const
|
|
49
|
+
const raw = typeof hit?.value === 'string' && hit.value.length > 0
|
|
49
50
|
? hit.value : envValue(ref);
|
|
51
|
+
const value = raw ? decryptSecret(raw) : raw;
|
|
50
52
|
if (!value || !isActive()) return { ok: false };
|
|
51
53
|
return runner.probeModels(ref, value);
|
|
52
54
|
}, Date.now(), { isActive });
|
package/lib/ops-keys.js
CHANGED
|
@@ -124,16 +124,27 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
124
124
|
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
125
125
|
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
126
126
|
if (provider) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
let found = false;
|
|
128
|
+
let cleared = 0;
|
|
129
|
+
for (const [key, st] of poolState.entries()) {
|
|
130
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
131
|
+
found = true;
|
|
132
|
+
cleared += st.failedUntil ? st.failedUntil.size : 0;
|
|
133
|
+
st.failedUntil?.clear();
|
|
134
|
+
st.failCounts?.clear();
|
|
135
|
+
st.authFailCounts?.clear();
|
|
136
|
+
st.brokenUntil?.clear();
|
|
137
|
+
st.revokedRefs?.clear();
|
|
138
|
+
st.tokenUsage?.clear();
|
|
139
|
+
st.switches = 0;
|
|
140
|
+
st.lastReason = undefined;
|
|
141
|
+
st.lastSwitchAt = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!found) {
|
|
145
|
+
json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
137
148
|
let circuitReset = false;
|
|
138
149
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
139
150
|
if (br) {
|
|
@@ -146,7 +157,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
146
157
|
if (ref) {
|
|
147
158
|
let found = false;
|
|
148
159
|
for (const st of poolState.values()) {
|
|
149
|
-
if (
|
|
160
|
+
if (
|
|
161
|
+
st.failedUntil?.has(ref) ||
|
|
162
|
+
st.failCounts?.has(ref) ||
|
|
163
|
+
st.authFailCounts?.has(ref) ||
|
|
164
|
+
st.brokenUntil?.has(ref) ||
|
|
165
|
+
st.revokedRefs?.has(ref) ||
|
|
166
|
+
st.tokenUsage?.has(ref) ||
|
|
167
|
+
st.lastUsed === ref
|
|
168
|
+
) {
|
|
150
169
|
st.failedUntil?.delete(ref);
|
|
151
170
|
st.failCounts?.delete(ref);
|
|
152
171
|
st.authFailCounts?.delete(ref);
|
|
@@ -154,11 +173,14 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
154
173
|
st.revokedRefs?.delete(ref);
|
|
155
174
|
st.tokenUsage?.delete(ref);
|
|
156
175
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
157
|
-
found = true;
|
|
176
|
+
found = true;
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
161
|
-
if (!found && !isValidRef(ref)) {
|
|
180
|
+
if (!found && !isValidRef(ref)) {
|
|
181
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
162
184
|
json(res, 200, { ok: true, ref });
|
|
163
185
|
return;
|
|
164
186
|
}
|
package/lib/ops-test.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TEST_PATH,
|
|
15
15
|
SANDBOX_CACHE_PATH,
|
|
16
16
|
} from './ops-paths.js';
|
|
17
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @param {object} ctx cordis context
|
|
@@ -43,7 +44,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
43
44
|
try {
|
|
44
45
|
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
45
46
|
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
46
|
-
const
|
|
47
|
+
const rawValue = testValue || hit?.value;
|
|
48
|
+
const effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
|
|
47
49
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
48
50
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
49
51
|
let source = null;
|
|
@@ -63,11 +65,12 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
63
65
|
lastTestCache.set(ref, cached);
|
|
64
66
|
if (cached.ok) {
|
|
65
67
|
for (const st of poolState.values()) {
|
|
66
|
-
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
68
|
+
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref) || st.revokedRefs?.has(ref)) {
|
|
67
69
|
st.failedUntil?.delete(ref);
|
|
68
70
|
st.failCounts?.delete(ref);
|
|
69
71
|
st.authFailCounts?.delete(ref);
|
|
70
72
|
st.brokenUntil?.delete(ref);
|
|
73
|
+
st.revokedRefs?.delete(ref);
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
}
|
package/lib/ops-webhook.js
CHANGED
|
@@ -153,24 +153,50 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
153
153
|
}
|
|
154
154
|
if (action.startsWith('pause-')) {
|
|
155
155
|
const until = Date.now() + 3600000; // 1h pause
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
let found = false;
|
|
157
|
+
for (const [key, s] of poolState.entries()) {
|
|
158
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
159
|
+
found = true;
|
|
160
|
+
for (const ref of (s.failedUntil ? [...s.failedUntil.keys()] : [])) {
|
|
161
|
+
s.failedUntil.set(ref, Math.max(s.failedUntil.get(ref) ?? 0, until));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
158
165
|
for (const p of buildRuntime().poolByRef.values()) {
|
|
159
|
-
if (p.base
|
|
160
|
-
|
|
166
|
+
if (p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) {
|
|
167
|
+
found = true;
|
|
168
|
+
for (const ref of p.refs) p.state?.failedUntil?.set(ref, Math.max(p.state?.failedUntil?.get(ref) ?? 0, until));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!found) {
|
|
172
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
173
|
+
return;
|
|
161
174
|
}
|
|
162
175
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
163
176
|
await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
|
|
164
177
|
return;
|
|
165
178
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
let found = false;
|
|
180
|
+
let cleared = 0;
|
|
181
|
+
for (const [key, s] of poolState.entries()) {
|
|
182
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
183
|
+
found = true;
|
|
184
|
+
cleared += s.failedUntil ? s.failedUntil.size : 0;
|
|
185
|
+
s.failedUntil?.clear();
|
|
186
|
+
s.failCounts?.clear();
|
|
187
|
+
s.authFailCounts?.clear();
|
|
188
|
+
s.brokenUntil?.clear();
|
|
189
|
+
s.revokedRefs?.clear();
|
|
190
|
+
s.tokenUsage?.clear();
|
|
191
|
+
s.switches = 0;
|
|
192
|
+
s.lastReason = undefined;
|
|
193
|
+
s.lastSwitchAt = undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!found) {
|
|
197
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
174
200
|
let circuitReset = false;
|
|
175
201
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
176
202
|
if (br) {
|
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,15 +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);
|
|
162
|
-
pool.state.failCounts?.delete(candidate);
|
|
163
|
-
pool.state.failedUntil.delete(candidate);
|
|
164
|
-
pool.state.authFailCounts?.delete(candidate);
|
|
165
|
-
pool.state.brokenUntil?.delete(candidate);
|
|
239
|
+
onPicked?.(pool, candidate, { concurrencyRef });
|
|
166
240
|
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
167
241
|
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
168
242
|
if (pool.perHour) {
|
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();
|
|
@@ -192,7 +192,30 @@ export function createRotate(deps) {
|
|
|
192
192
|
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
193
193
|
activePermit = breaker ? breaker.acquire(options.provider) : null;
|
|
194
194
|
if (breaker && !activePermit) {
|
|
195
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open —
|
|
195
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — trying cascade fallback`);
|
|
196
|
+
const runtimeQ = buildRuntime();
|
|
197
|
+
const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
198
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
199
|
+
if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
|
|
200
|
+
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model, runtimeQ.modelPoolByProvider, breaker);
|
|
201
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
202
|
+
pool.state.lastReason = 'CASCADE';
|
|
203
|
+
pool.state.lastSwitchAt = now();
|
|
204
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
205
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
206
|
+
const innerCascade = rotate({
|
|
207
|
+
...options,
|
|
208
|
+
provider: fb.provider,
|
|
209
|
+
model: fb.model || options.model,
|
|
210
|
+
__isCascade: true,
|
|
211
|
+
__cascadeDepth: currentDepth + 1,
|
|
212
|
+
}, fb.pool);
|
|
213
|
+
for await (const chunk of innerCascade) {
|
|
214
|
+
yield chunk;
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
196
219
|
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
197
220
|
return;
|
|
198
221
|
}
|
|
@@ -225,7 +248,10 @@ export function createRotate(deps) {
|
|
|
225
248
|
}
|
|
226
249
|
|
|
227
250
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
228
|
-
|
|
251
|
+
if (_pickedRef && !reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
|
|
252
|
+
const ok = concurrencyTracker.acquire(_pickedRef, now(), pool.concurrencyLimit ?? runtime0.concurrencyLimit ?? 0);
|
|
253
|
+
if (ok) reqStore.concurrencyRef = _pickedRef;
|
|
254
|
+
}
|
|
229
255
|
try {
|
|
230
256
|
try {
|
|
231
257
|
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
@@ -398,11 +424,12 @@ export function createRotate(deps) {
|
|
|
398
424
|
}
|
|
399
425
|
return; // clean end — served
|
|
400
426
|
} finally {
|
|
401
|
-
if (acquired && _pickedRef) {
|
|
402
|
-
concurrencyTracker.release(_pickedRef);
|
|
403
|
-
}
|
|
404
427
|
}
|
|
405
428
|
} finally {
|
|
429
|
+
if (reqStore.concurrencyRef) {
|
|
430
|
+
concurrencyTracker?.release(reqStore.concurrencyRef);
|
|
431
|
+
reqStore.concurrencyRef = null;
|
|
432
|
+
}
|
|
406
433
|
// Also runs on consumer return(), abort, empty EOF and local errors.
|
|
407
434
|
activePermit?.release();
|
|
408
435
|
}
|
|
@@ -421,7 +448,7 @@ export function createRotate(deps) {
|
|
|
421
448
|
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
422
449
|
if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
423
450
|
const pools = runtime.providerToPool;
|
|
424
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
|
|
451
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools, options.model, runtime.modelPoolByProvider, circuitBreaker);
|
|
425
452
|
if (fb && fb.pool && fb.pool !== pool) {
|
|
426
453
|
logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
|
|
427
454
|
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.41",
|
|
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": [
|
|
@@ -69,7 +69,11 @@
|
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"react": "18.3.1",
|
|
72
|
-
"react-test-renderer": "18.3.1"
|
|
72
|
+
"react-test-renderer": "18.3.1",
|
|
73
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
74
|
+
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
|
|
75
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
76
|
+
"semver": "^7.6.3"
|
|
73
77
|
},
|
|
74
78
|
"dependencies": {
|
|
75
79
|
"undici": "^6.29.0"
|