@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 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
@@ -1,6 +1,7 @@
1
1
  // lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
2
2
  import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
3
3
  import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
4
+ import { getProviderCost } from './resolver.js';
4
5
 
5
6
  const DAY_MS = 86400000;
6
7
 
@@ -17,58 +18,61 @@ export function checkBudgetAndHealthAlerts({
17
18
  logger,
18
19
  }) {
19
20
  try {
20
- const seen = new Set();
21
- for (const pool of runtime.poolByRef.values()) {
22
- if (seen.has(pool.base)) continue;
23
- seen.add(pool.base);
24
- // #207: keys expiring within expiryWarnDays -> one webhook per key/day
25
- for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
26
- if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
27
- logger?.warn?.(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
28
- if (runtime.notifyWebhook) {
29
- webhookSender.send(runtime.notifyWebhook, {
30
- title: `Key expiring soon: ${pool.base}`,
31
- text: `${ref} expires in ~${expiresInDays} day(s)`,
32
- provider: pool.base,
33
- kind: 'expiry',
34
- keys: [ref],
35
- });
36
- }
37
- }
38
- // #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
39
- const budget = runtime.providerBudgets?.get?.(pool.base);
40
- if (budget) {
41
- const daily = costForDay(pool.state.costDays);
42
- const weekly = costForWeek(pool.state.costDays, now);
21
+ // #208 / #422: provider monetary budget alerts & pause across base and model pools
22
+ if (runtime.providerBudgets && runtime.providerBudgets.size > 0) {
23
+ for (const [provider, budget] of runtime.providerBudgets) {
24
+ const { daily, weekly, pools } = getProviderCost(provider, runtime, now);
43
25
  const verdict = budgetVerdict(daily, budget.costBudgetDaily);
44
26
  const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
45
27
  const hit = verdict.warn || wVerdict.warn;
46
- if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
47
- logger?.warn?.(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
28
+ if (hit && shouldNotifyDaily(budgetNotifiedAt, provider + ':budget', now)) {
29
+ logger?.warn?.(`[dsh-key-rotation] ${provider}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
48
30
  if (runtime.notifyWebhook) {
49
- // #217: budget webhook gains action buttons when a callback token is configured
50
31
  const token = runtime.webhookActionToken ?? '';
51
32
  webhookSender.send(runtime.notifyWebhook, {
52
- title: `Cost budget: ${pool.base}`,
33
+ title: `Cost budget: ${provider}`,
53
34
  text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
54
- provider: pool.base,
35
+ provider,
55
36
  kind: 'budget',
56
37
  spend: { daily, weekly },
57
38
  actionToken: token || undefined,
58
39
  actions: token ? [
59
- { id: `pause-${pool.base}`, label: 'Pause 1h' },
60
- { id: `reset-${pool.base}`, label: 'Reset cooldown' },
40
+ { id: `pause-${provider}`, label: 'Pause 1h' },
41
+ { id: `reset-${provider}`, label: 'Reset cooldown' },
61
42
  ] : undefined,
62
43
  });
63
44
  }
64
45
  }
65
46
  if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
66
47
  const until = now + DAY_MS;
67
- for (const ref of pool.refs) {
68
- if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
48
+ for (const p of pools) {
49
+ for (const ref of p.refs) {
50
+ if ((p.state.failedUntil.get(ref) ?? 0) < until) p.state.failedUntil.set(ref, until);
51
+ }
69
52
  }
70
53
  }
71
54
  }
55
+ }
56
+
57
+ const seen = new Set();
58
+ for (const pool of runtime.poolByRef.values()) {
59
+ if (seen.has(pool.base)) continue;
60
+ seen.add(pool.base);
61
+ // #207: keys expiring within expiryWarnDays -> one webhook per key/day
62
+ for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
63
+ if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
64
+ logger?.warn?.(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
65
+ if (runtime.notifyWebhook) {
66
+ webhookSender.send(runtime.notifyWebhook, {
67
+ title: `Key expiring soon: ${pool.base}`,
68
+ text: `${ref} expires in ~${expiresInDays} day(s)`,
69
+ provider: pool.base,
70
+ kind: 'expiry',
71
+ keys: [ref],
72
+ });
73
+ }
74
+ }
75
+ // #208 / #422: handled provider-wide before pool loop
72
76
  // #221: pool running low - webhook while healthy < warnBelowHealthy
73
77
  const warnBelow = runtime.warnBelowHealthy ?? 0;
74
78
  if (warnBelow > 0) {
package/lib/cascade.js CHANGED
@@ -1,28 +1,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
- const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
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;
@@ -1,4 +1,4 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407).
2
2
 
3
3
  const CONCURRENCY_DEFAULT_LIMIT = 0;
4
4
  const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
@@ -12,12 +12,22 @@ export class ConcurrencyTracker {
12
12
  this._inFlight = new Map();
13
13
  }
14
14
 
15
+ configure(opts) {
16
+ if (!opts) return;
17
+ if (opts.limit !== undefined) {
18
+ this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
19
+ }
20
+ if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
21
+ this._staleMs = opts.staleMs;
22
+ }
23
+ }
24
+
15
25
  isEnabled() { return this._limit > 0; }
16
26
  get limit() { return this._limit; }
17
27
 
18
- acquire(ref, now) {
28
+ acquire(ref, now, limit) {
19
29
  now = now || Date.now();
20
- if (!this.isEnabled()) return true;
30
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
21
31
  let e = this._inFlight.get(ref);
22
32
  if (!e) {
23
33
  e = { count: 0, lastAcquired: now };
@@ -26,7 +36,7 @@ export class ConcurrencyTracker {
26
36
  if (now - e.lastAcquired > this._staleMs) {
27
37
  e.count = 0;
28
38
  }
29
- if (e.count >= this._limit) return false;
39
+ if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
30
40
  e.count += 1;
31
41
  e.lastAcquired = now;
32
42
  return true;
@@ -55,8 +65,9 @@ export class ConcurrencyTracker {
55
65
  return out;
56
66
  }
57
67
 
58
- pickLeastLoaded(candidates, now, weights) {
68
+ pickLeastLoaded(candidates, now, weights, limit) {
59
69
  now = now || Date.now();
70
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
60
71
  if (!Array.isArray(candidates) || candidates.length === 0) return null;
61
72
  let best = null;
62
73
  let bestScore = Infinity;
@@ -64,7 +75,7 @@ export class ConcurrencyTracker {
64
75
  const e = this._inFlight.get(ref);
65
76
  let count = e ? e.count : 0;
66
77
  if (e && now - e.lastAcquired > this._staleMs) count = 0;
67
- if (this.isEnabled() && count >= this._limit) continue;
78
+ if (effectiveLimit > 0 && count >= effectiveLimit) continue;
68
79
  const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
69
80
  const score = count / w;
70
81
  if (score < bestScore) {
package/lib/index.js CHANGED
@@ -324,6 +324,7 @@ export function apply(ctx, config = {}) {
324
324
  budgetNotifiedAt,
325
325
  });
326
326
 
327
+ concurrencyTracker.configure({ limit: concurrencyLimit });
327
328
  cachedRuntime = {
328
329
  switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold,
329
330
  concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, tpmLimit,
@@ -355,9 +356,12 @@ export function apply(ctx, config = {}) {
355
356
  const resolve = createResolver({
356
357
  buildRuntime,
357
358
  currentPool: () => dispatchStorage.getStore()?.pool,
358
- onPicked: (pool, candidate) => {
359
+ onPicked: (pool, candidate, meta) => {
359
360
  const store = dispatchStorage.getStore();
360
- if (store && store.pool === pool) store.pickedRef = candidate;
361
+ if (store && store.pool === pool) {
362
+ store.pickedRef = candidate;
363
+ if (meta?.concurrencyRef) store.concurrencyRef = meta.concurrencyRef;
364
+ }
361
365
  },
362
366
  latencyHistogram,
363
367
  concurrencyTracker,
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 value = typeof hit?.value === 'string' && hit.value.length > 0
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
- const st = poolState.get(provider);
128
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
129
- const cleared = st.failedUntil.size;
130
- st.failedUntil.clear();
131
- st.failCounts?.clear();
132
- st.authFailCounts?.clear();
133
- st.brokenUntil?.clear();
134
- st.revokedRefs?.clear();
135
- st.tokenUsage?.clear();
136
- st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
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 (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref) || st.tokenUsage?.has(ref)) {
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; break;
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)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
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 effectiveValue = testValue || hit?.value;
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
  }
@@ -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
- for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
157
- // also pause every key currently healthy
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 !== provider) continue;
160
- for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
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
- const cleared = st.failedUntil.size;
167
- st.failedUntil.clear();
168
- st.failCounts?.clear();
169
- st.authFailCounts?.clear();
170
- st.brokenUntil?.clear();
171
- st.switches = 0;
172
- st.lastReason = undefined;
173
- st.lastSwitchAt = undefined;
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) {
@@ -27,6 +27,7 @@ export function buildPoolItem({
27
27
  poolGuard,
28
28
  rpmLimit,
29
29
  tpmLimit = 0,
30
+ concurrencyLimit = 0,
30
31
  makeState,
31
32
  provider = null,
32
33
  model = null,
@@ -103,6 +104,7 @@ export function buildPoolItem({
103
104
  expiresAt: parsedExpiry,
104
105
  rpmLimit,
105
106
  tpmLimit: typeof tpmLimit === 'number' && Number.isFinite(tpmLimit) && tpmLimit > 0 ? Math.floor(tpmLimit) : 0,
107
+ concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
106
108
  routingStrategy: poolStrategy,
107
109
  proactiveRateLimitGuard: poolGuard,
108
110
  };
@@ -111,6 +113,7 @@ export function buildPoolItem({
111
113
  export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
112
114
  const rpmLimit = cfg.rpmLimit ?? 0;
113
115
  const tpmLimit = cfg.tpmLimit ?? 0;
116
+ const concurrencyLimit = cfg.concurrencyLimit ?? 0;
114
117
  const quotaResetWindow = cfg.quotaResetWindow ?? null;
115
118
  const poolByRef = new Map();
116
119
  const providerToPool = new Map();
@@ -127,10 +130,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
127
130
  return initializePoolState(st);
128
131
  };
129
132
 
130
- const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit) =>
133
+ const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta, pRpm = rpmLimit, pTpm = tpmLimit, pConcurrency = concurrencyLimit) =>
131
134
  buildPoolItem({
132
135
  base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
133
- rpmLimit: pRpm, tpmLimit: pTpm, makeState, quotaResetWindow, ...meta,
136
+ rpmLimit: pRpm, tpmLimit: pTpm, concurrencyLimit: pConcurrency, makeState, quotaResetWindow, ...meta,
134
137
  });
135
138
 
136
139
  for (const p of cfg.providers ?? []) {
@@ -138,10 +141,11 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
138
141
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
139
142
  const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
140
143
  const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
144
+ const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
141
145
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
142
146
  provider: p.provider,
143
147
  model: null,
144
- }, provRpm, provTpm);
148
+ }, provRpm, provTpm, provConcurrency);
145
149
  if (pool) {
146
150
  for (const ref of pool.refs) {
147
151
  if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
@@ -153,6 +157,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
153
157
  for (const [model, mp] of Object.entries(p.models ?? {})) {
154
158
  const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
155
159
  const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
160
+ const modelConcurrency = typeof mp.concurrencyLimit === 'number' ? mp.concurrencyLimit : provConcurrency;
156
161
  const mergedPaused = new Set([
157
162
  ...(pool?.pausedRefs ?? []),
158
163
  ...(Array.isArray(mp.paused) ? mp.paused : (mp.paused instanceof Set ? [...mp.paused] : [])),
@@ -166,7 +171,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
166
171
  provider: p.provider,
167
172
  model,
168
173
  quotas: mp.quotas,
169
- }, modelRpm, modelTpm);
174
+ }, modelRpm, modelTpm, modelConcurrency);
170
175
  if (!mpool) continue;
171
176
  mpool.basePool = pool;
172
177
  byModel.set(model, mpool);
package/lib/resolver.js CHANGED
@@ -1,4 +1,3 @@
1
- // lib/resolver.js — the credential selector behind credentials.resolve (#381).
2
1
  //
3
2
  // Extracted from lib/index.js so the routing decisions that this feature
4
3
  // depends on — request-scoped pool ownership, model token quota enforcement,
@@ -10,7 +9,7 @@
10
9
  // delegates to `original(ref)`.
11
10
 
12
11
  import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
13
- import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked } from './pool.js';
12
+ import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, costForDay, costForWeek, budgetVerdict } from './pool.js';
14
13
  import { decryptSecret } from './crypto-storage.js';
15
14
  import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
16
15
  import {
@@ -24,11 +23,51 @@ import {
24
23
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
25
24
  export const LOCAL_POOL_EXHAUSTED_CODE = 'LOCAL_POOL_EXHAUSTED';
26
25
 
26
+ /**
27
+ * Calculates aggregated daily and weekly spend across base pool and model pools for a provider without double-counting (#422).
28
+ */
29
+ export function getProviderCost(provider, runtime, now = Date.now()) {
30
+ const seenStates = new Set();
31
+ let daily = 0;
32
+ let weekly = 0;
33
+ const pools = [];
34
+ const basePool = runtime?.providerToPool?.get?.(provider);
35
+ if (basePool) pools.push(basePool);
36
+ const modelPools = runtime?.modelPoolByProvider?.get?.(provider);
37
+ if (modelPools) {
38
+ for (const mp of modelPools.values()) {
39
+ if (!pools.includes(mp)) pools.push(mp);
40
+ }
41
+ }
42
+ if (runtime?.index?.allPools) {
43
+ for (const p of runtime.index.allPools) {
44
+ if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
45
+ pools.push(p);
46
+ }
47
+ }
48
+ }
49
+ if (runtime?.poolByRef) {
50
+ for (const p of runtime.poolByRef.values()) {
51
+ if ((p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) && !pools.includes(p)) {
52
+ pools.push(p);
53
+ }
54
+ }
55
+ }
56
+ for (const p of pools) {
57
+ if (p.state && !seenStates.has(p.state)) {
58
+ seenStates.add(p.state);
59
+ daily += costForDay(p.state.costDays);
60
+ weekly += costForWeek(p.state.costDays, now);
61
+ }
62
+ }
63
+ return { daily, weekly, pools };
64
+ }
65
+
27
66
  /**
28
67
  * @param {object} deps
29
68
  * @param {() => object} deps.buildRuntime current runtime snapshot
30
69
  * @param {() => object|undefined} deps.currentPool request-scoped pool (ALS)
31
- * @param {(pool: object, candidate: string) => void} [deps.onPicked] picked-ref hook
70
+ * @param {(pool: object, candidate: string, meta?: object) => void} [deps.onPicked] picked-ref hook
32
71
  * @param {object} [deps.latencyHistogram]
33
72
  * @param {object} [deps.concurrencyTracker]
34
73
  * @param {() => number} [deps.now] wall clock; injectable for tests
@@ -60,6 +99,22 @@ export function createResolver({
60
99
  }
61
100
 
62
101
  const at = now();
102
+
103
+ // Provider monetary budget refusal (#422):
104
+ const providerName = pool.provider ?? pool.base?.split?.('::')?.[0];
105
+ const budget = runtime.providerBudgets?.get?.(providerName);
106
+ if (budget && budget.pauseOnBudget) {
107
+ const { daily, weekly } = getProviderCost(providerName, runtime, at);
108
+ const vDaily = budgetVerdict(daily, budget.costBudgetDaily);
109
+ const vWeekly = budgetVerdict(weekly, budget.costBudgetWeekly);
110
+ if (vDaily.exceeded || vWeekly.exceeded) {
111
+ throw Object.assign(
112
+ new Error(`dsh-key-rotation: provider "${providerName}" cost budget exceeded (daily: $${daily.toFixed(2)}/${budget.costBudgetDaily}, weekly: $${weekly.toFixed(2)}/${budget.costBudgetWeekly})`),
113
+ { code: LOCAL_POOL_EXHAUSTED_CODE, localExhausted: true },
114
+ );
115
+ }
116
+ }
117
+
63
118
  const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
64
119
  let list = pool.weightedRefs ?? pool.refs;
65
120
  if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
@@ -82,9 +137,6 @@ export function createResolver({
82
137
  const candidateExp = pool.expiresAt?.[candidate] ?? pool.basePool?.expiresAt?.[candidate];
83
138
  if (candidateExp !== undefined && at >= candidateExp) continue;
84
139
  // Local model token budget: a spent credential is simply not a candidate.
85
- // This is deliberately NOT recorded as a failure — the key is healthy and
86
- // merely out of budget — so it must never touch failedUntil / failCounts /
87
- // authFailCounts / brokenUntil.
88
140
  if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
89
141
  const rpmLimit = pool.rpmLimit ?? 0;
90
142
  if (rpmLimit > 0) {
@@ -119,20 +171,46 @@ export function createResolver({
119
171
  }
120
172
  }
121
173
 
174
+ // Concurrency limit per-key check (#407):
175
+ const concurrencyLimit = pool.concurrencyLimit ?? runtime.concurrencyLimit ?? 0;
176
+ if (concurrencyLimit > 0 && concurrencyTracker && typeof concurrencyTracker.getActive === 'function') {
177
+ if (concurrencyTracker.getActive(candidate, at) >= concurrencyLimit) {
178
+ continue;
179
+ }
180
+ }
181
+
182
+ // Atomically acquire concurrency permit upon candidate selection (#407):
183
+ let acquired = false;
184
+ if (concurrencyTracker && typeof concurrencyTracker.acquire === 'function') {
185
+ const ok = concurrencyTracker.acquire(candidate, at, concurrencyLimit);
186
+ if (!ok) continue;
187
+ acquired = true;
188
+ }
189
+
122
190
  // Advance the shared cursor at selection time: concurrent requests must
123
191
  // not all pick the same slot.
124
192
  pool.state.pointer = (index + 1) % list.length;
125
- const hit = await original(candidate);
126
- if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
127
- settle(pool, candidate, at, onPicked);
128
- return { ...hit, value: decryptSecret(hit.value) };
193
+ try {
194
+ const hit = await original(candidate);
195
+ if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
196
+ settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
197
+ return { ...hit, value: decryptSecret(hit.value) };
198
+ }
199
+ // No stored credential: fall back to the launching environment, which is
200
+ // how env-bootstrapped pools work (issue #7).
201
+ const envVal = envValue(candidate);
202
+ if (envVal !== undefined) {
203
+ settle(pool, candidate, at, onPicked, acquired ? candidate : undefined);
204
+ return { value: decryptSecret(envVal), source: 'env' };
205
+ }
206
+ } catch (err) {
207
+ if (acquired && typeof concurrencyTracker.release === 'function') {
208
+ concurrencyTracker.release(candidate, at);
209
+ }
210
+ throw err;
129
211
  }
130
- // No stored credential: fall back to the launching environment, which is
131
- // how env-bootstrapped pools work (issue #7).
132
- const envVal = envValue(candidate);
133
- if (envVal !== undefined) {
134
- settle(pool, candidate, at, onPicked);
135
- return { value: decryptSecret(envVal), source: 'env' };
212
+ if (acquired && typeof concurrencyTracker.release === 'function') {
213
+ concurrencyTracker.release(candidate, at);
136
214
  }
137
215
  }
138
216
 
@@ -154,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 — skipping dispatch`);
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
- const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
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.39",
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"