@goodandready/dsh-key-rotation 0.8.80 → 0.8.81

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.81 (2026-10-30)
4
+
5
+ ### Direct Extension Pipeline & Pure Key Resolution (Block 9 Release)
6
+ - **Direct Extension Pipeline without Monkey-Patching (Issue #526)**: Introduced clean credential injection and direct resolution pipeline via `resolveKeyDirectly` (`lib/resolver.js`). Bypasses monkey-patching of global `credentials.resolve` and direct mutations on foreign services.
7
+ - **Explicit Stream Options Delivery (`lib/rotate.js`)**: Passes resolved secrets directly through `directOpts` (`apiKey`, `headers.Authorization`, `_rotationKeyRef`) into `llm.stream`, allowing provider adapters to consume credentials without touching global process state. Preserved `dispatchStorage` as backward-compatible fallback for legacy runtimes.
8
+ - **Dedicated Key Penalty Manager (`lib/rotate-penalty.js`)**: Extracted `penalizeKey` into dedicated module (<200 lines) with dynamic header rate-limit tracking, backoff computation, quota window synchronization, early warning notifications, and cross-pool permanent pause fan-out, maintaining `lib/rotate.js` well below the 600-line modularity limit.
9
+
10
+ ## 0.8.80 (2026-10-30)
11
+
12
+ ### Layered Core Decoupling, Append-Only Journal & Dynamic Cordis Injection (Block 8 Release)
13
+ - **Layered Core Architecture (Issue #530)**: Reorganized algorithmic core into `lib/core/` (`state-machine.js`, `concurrency.js`, `quota.js`, `pool.js`, `rotate.js`, `index.js`) with backwards-compatible top-level facades in `lib/*.js`.
14
+ - **Dynamic Cordis Injection (Issue #528)**: Decoupled optional dependencies via dynamic `ctx.inject(['webServer'], ...)`, minimizing static requirements to `['llm']` and ensuring smooth headless/CLI execution.
15
+ - **Append-Only Journal Persistence (Issue #525)**: Implemented incremental append-only journaling (`*-journal.jsonl`) in `StatePersistence`, eliminating O(N) full-disk state serialization on routine key cooldowns and rotation events with automated rotation upon `flush()`.
16
+
17
+ ## 0.8.79 (2026-10-30)
18
+
19
+ ### Modular Route Layer, Schemastery Volatile & Key State Machine (Block 7 Release)
20
+ - **Modular Route Structure (Issue #522)**: Extracted route handlers into dedicated `lib/routes/` directory (`status.js`, `keys.js`, `metrics.js`, `balance.js`, `telemetry.js`, `testing.js`, `webhook.js`, `config.js`, `quick-action.js`, `guard.js`, `paths.js`) with legacy compatibility facades in `lib/ops-*.js`.
21
+ - **Schemastery Volatile Config Migration (Issue #551)**: Implemented `unwrapVolatileConfig` and deprecation headers (`X-DSH-Deprecated`, `Deprecation`) on deprecated config endpoints.
22
+ - **Key State Machine (Issue #529)**: Introduced finite state machine `KeyStateMachine` (`lib/key-state-machine.js`) with strict transition matrix (`VALID_TRANSITIONS`), audit trail, and state statistics.
23
+
3
24
  ## 0.8.78 (2026-10-30)
4
25
 
5
26
  ### Network Transports, Model Catalog & Predictive Quota Runway (Block 6 Release)
package/lib/resolver.js CHANGED
@@ -309,3 +309,39 @@ function settle(pool, candidate, now, onPicked, concurrencyRef, sessionId) {
309
309
  pool.state.quotaWindows.set(candidate, win);
310
310
  }
311
311
  }
312
+
313
+ /**
314
+ * Resolves credential secret directly without requiring monkey-patching (#526).
315
+ * Reads from credentials service safely or falls back to environment variables.
316
+ * @param {string} candidateRef
317
+ * @param {object} [ctx]
318
+ * @returns {Promise<string|null>}
319
+ */
320
+ export async function resolveKeyDirectly(candidateRef, ctx) {
321
+ if (!candidateRef || typeof candidateRef !== 'string') return null;
322
+ try {
323
+ const creds = ctx?.get?.('credentials') ?? ctx?.credentials;
324
+ if (creds) {
325
+ if (typeof creds.get === 'function') {
326
+ const val = await creds.get(candidateRef);
327
+ if (val) return decryptSecret(typeof val === 'object' ? val.value : val);
328
+ }
329
+ if (typeof creds.resolve === 'function') {
330
+ const orig = creds.__dshKeyRotationOriginalResolve ?? creds.resolve;
331
+ const hit = await orig.call(creds, candidateRef);
332
+ if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
333
+ return decryptSecret(hit.value);
334
+ }
335
+ }
336
+ }
337
+ } catch {
338
+ /* credentials miss or access error falls through to env */
339
+ }
340
+
341
+ const envVal = envValue(candidateRef);
342
+ if (envVal !== undefined && typeof envVal === 'string' && envVal.length > 0) {
343
+ return decryptSecret(envVal);
344
+ }
345
+
346
+ return null;
347
+ }
@@ -0,0 +1,193 @@
1
+ // lib/rotate-penalty.js — Key penalty, backoff, dynamic headers, and failure tracking (#526).
2
+ import { classifyFailure } from './error-taxonomy.js';
3
+ import {
4
+ recordFailure,
5
+ parseRetryAfter,
6
+ isKeyPaused,
7
+ isKeyRevoked,
8
+ isKeyBroken,
9
+ clearSessionSticky,
10
+ unstickFailedKey,
11
+ } from './pool.js';
12
+ import { extractProviderLimits } from './provider-limits-detector.js';
13
+ import { poolResetAt } from './quota-window.js';
14
+
15
+ export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
16
+
17
+ /**
18
+ * Penalize a key upon error or upstream failure during rotation.
19
+ */
20
+ export function penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
21
+ pool,
22
+ options,
23
+ sessionId,
24
+ runtime0,
25
+ cooldownMs,
26
+ maxCooldownMs,
27
+ now,
28
+ activePermit,
29
+ trace,
30
+ pushEvent,
31
+ schedulePersist,
32
+ effectiveNotifyEarlyWarning,
33
+ logWarn,
34
+ buildRuntime,
35
+ }) {
36
+ if (!targetRef) return;
37
+ if (sessionId) clearSessionSticky(pool, sessionId);
38
+ unstickFailedKey(pool, targetRef);
39
+ if (errCode === LOCAL_QUOTA_CODE) return;
40
+
41
+ let headerResetMs = null;
42
+ if (rawHeaders) {
43
+ const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
44
+ ? rawHeaders
45
+ : extractProviderLimits(rawHeaders);
46
+ headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
47
+ }
48
+ if (!headerResetMs) {
49
+ const pRetry = parseRetryAfter(errMsg);
50
+ if (pRetry) headerResetMs = pRetry;
51
+ }
52
+
53
+ const _base = pool.cooldownMs ?? cooldownMs;
54
+ const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
55
+ let _b = 0;
56
+ const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
57
+
58
+ if (headerResetMs && headerResetMs > 0) {
59
+ const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
60
+ pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
61
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
62
+ pool.state.resetReasons.set(targetRef, 'header');
63
+ if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
64
+ pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
65
+ _b = dynamicCooldownMs;
66
+ pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
67
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
68
+
69
+ const recTimer = setTimeout(() => {
70
+ if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
71
+ pool.state.failedUntil.delete(targetRef);
72
+ pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
73
+ }
74
+ }, dynamicCooldownMs + 50);
75
+ if (typeof recTimer.unref === 'function') recTimer.unref();
76
+ } else {
77
+ const _effBase = _base;
78
+ _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
79
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
80
+ pool.state.resetReasons.set(targetRef, 'backoff');
81
+ }
82
+
83
+ // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
84
+ // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
85
+ // must NOT be locked until midnight. Keep those on ordinary cooldowns.
86
+ const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
87
+ (/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
88
+
89
+ const isQuota = !isRateThrottling && (
90
+ (errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
91
+ /\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
92
+ );
93
+ const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
94
+ if (isQuota && qWindow) {
95
+ const quotaResetAt = poolResetAt(pool, qWindow, now());
96
+ if (quotaResetAt && quotaResetAt > now()) {
97
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
98
+ pool.state.quotaWindows.set(targetRef, quotaResetAt);
99
+ pool.state.failedUntil.set(targetRef, quotaResetAt);
100
+ }
101
+ }
102
+
103
+ activePermit?.failure();
104
+ trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
105
+ pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
106
+ if (typeof schedulePersist === 'function') {
107
+ try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
108
+ }
109
+ if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
110
+ const total = pool.refs?.length || 1;
111
+ let available = 0;
112
+ const at = now();
113
+ for (const r of (pool.refs || [])) {
114
+ if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
115
+ available++;
116
+ }
117
+ }
118
+ const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
119
+ if (total > 0 && (available / total) <= threshold) {
120
+ effectiveNotifyEarlyWarning(runtime0, pool, {
121
+ provider: options.provider,
122
+ availableCount: available,
123
+ totalCount: total,
124
+ });
125
+ }
126
+ }
127
+ if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
128
+ if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
129
+ const _cStr = String(errCode ?? '').toUpperCase();
130
+ const _isAuth = cls.reason === 'auth' || cls.code === '401' || cls.code === '403' ||
131
+ _cStr === 'AUTH' || _cStr === '401' || _cStr === '403' ||
132
+ _cStr === 'PERMISSION_DENIED' || _cStr === 'UNAUTHENTICATED' ||
133
+ /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated|permission[_\s-]?denied/i.test(errMsg);
134
+
135
+ if (_isAuth) {
136
+ const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
137
+ pool.state.authFailCounts.set(targetRef, _c2);
138
+ if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
139
+ pool.state.revokedRefs.add(targetRef);
140
+ if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
141
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(targetRef);
142
+ else if (Array.isArray(pool.state.pausedRefs) && !pool.state.pausedRefs.includes(targetRef)) pool.state.pausedRefs.push(targetRef);
143
+ if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
144
+ pool.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
145
+ pool.state.failedUntil.set(targetRef, Infinity);
146
+ pushEvent(pool, targetRef, 'KEY_AUTH_PAUSED', 0);
147
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently PAUSED on auth failure (${errCode})`);
148
+ // Fan-out permanent pause across all pools sharing this physical ref (#408):
149
+ if (typeof buildRuntime === 'function') {
150
+ const rAll = buildRuntime();
151
+ const candidatePools = [
152
+ ...(Array.isArray(rAll.pools) ? rAll.pools : []),
153
+ ...(rAll.index?.allPools ? rAll.index.allPools.values() : []),
154
+ ...(rAll.providerToPool ? rAll.providerToPool.values() : []),
155
+ ];
156
+ if (rAll.modelPoolByProvider) {
157
+ for (const map of rAll.modelPoolByProvider.values()) { if (map) candidatePools.push(...map.values()); }
158
+ }
159
+ const seenPools = new Set();
160
+ for (const other of candidatePools) {
161
+ if (!other || seenPools.has(other)) continue;
162
+ seenPools.add(other);
163
+ if (other === pool) continue;
164
+ const hasRef = (Array.isArray(other.refs) && other.refs.includes(targetRef)) ||
165
+ (other.weightsMap instanceof Map && other.weightsMap.has(targetRef));
166
+ if (hasRef && other.state) {
167
+ if (!other.state.revokedRefs) other.state.revokedRefs = new Set();
168
+ other.state.revokedRefs.add(targetRef);
169
+ if (!other.state.pausedRefs) other.state.pausedRefs = new Set();
170
+ if (other.state.pausedRefs instanceof Set) other.state.pausedRefs.add(targetRef);
171
+ else if (Array.isArray(other.state.pausedRefs) && !other.state.pausedRefs.includes(targetRef)) other.state.pausedRefs.push(targetRef);
172
+ if (!other.state.pauseReasons) other.state.pauseReasons = new Map();
173
+ other.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
174
+ if (other.state.failedUntil) other.state.failedUntil.set(targetRef, Infinity);
175
+ pushEvent(other, targetRef, 'KEY_AUTH_PAUSED', 0);
176
+ }
177
+ }
178
+ }
179
+ } else {
180
+ pool.state.authFailCounts.delete(targetRef);
181
+ // Soft/transient errors (TIMEOUT, 5xx, socket) do not count toward quarantine (#473).
182
+ // recordFailure already maintains pool.state.failCounts accurately for non-soft failures.
183
+ if (!isQuota && !cls.soft) {
184
+ const _fc = pool.state.failCounts.get(targetRef) ?? 0;
185
+ if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
186
+ const quarantineMs = 1800000;
187
+ pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
188
+ pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
189
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
190
+ }
191
+ }
192
+ }
193
+ }
package/lib/rotate.js CHANGED
@@ -1,12 +1,9 @@
1
1
  // lib/rotate.js — stream rotation / failover generator (#253).
2
2
  // Pure wiring helper: dependencies are injected so unit tests can supply mocks.
3
3
  import { nowMono } from './clock.js';
4
- import { classifyFailure } from './error-taxonomy.js';
5
4
  import {
6
5
  isSwitchableError,
7
- recordFailure,
8
6
  recordSuccess,
9
- parseRetryAfter,
10
7
  isKeyPaused,
11
8
  isKeyRevoked,
12
9
  isKeyBroken,
@@ -14,15 +11,12 @@ import {
14
11
  sortAttemptList,
15
12
  getSessionSticky,
16
13
  setSessionSticky,
17
- clearSessionSticky,
18
- unstickFailedKey,
19
14
  } from './pool.js';
20
15
  import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
21
16
  import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
22
17
  import { createSelectionTrace } from './selection-trace.js';
23
18
  import { isKeyDraining } from './key-drain.js';
24
19
  import { notifyEarlyWarning as defaultNotifyEarlyWarning } from './notify-events.js';
25
- import { nextQuotaReset, poolResetAt } from './quota-window.js';
26
20
  import {
27
21
  isModelQuotaAvailable,
28
22
  hasModelQuotaConfig,
@@ -30,9 +24,9 @@ import {
30
24
  formatModelQuotaExhaustion,
31
25
  } from './model-quota.js';
32
26
 
33
- import { LOCAL_POOL_EXHAUSTED_CODE } from './resolver.js';
27
+ import { LOCAL_POOL_EXHAUSTED_CODE, resolveKeyDirectly } from './resolver.js';
34
28
  import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
35
- import { extractProviderLimits } from './provider-limits-detector.js';
29
+ import { penalizeKey } from './rotate-penalty.js';
36
30
  import { recordPromptCache } from './prompt-cache.js';
37
31
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
38
32
 
@@ -209,160 +203,22 @@ export function createRotate(deps) {
209
203
  }
210
204
 
211
205
  const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
212
- if (!targetRef) return;
213
- if (sessionId) clearSessionSticky(pool, sessionId);
214
- unstickFailedKey(pool, targetRef);
215
- if (errCode === LOCAL_QUOTA_CODE) return;
216
- let headerResetMs = null;
217
- if (rawHeaders) {
218
- const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
219
- ? rawHeaders
220
- : extractProviderLimits(rawHeaders);
221
- headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
222
- }
223
- if (!headerResetMs) {
224
- const pRetry = parseRetryAfter(errMsg);
225
- if (pRetry) headerResetMs = pRetry;
226
- }
227
-
228
- const _base = pool.cooldownMs ?? cooldownMs;
229
- const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
230
- let _b = 0;
231
- const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
232
-
233
- if (headerResetMs && headerResetMs > 0) {
234
- const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
235
- pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
236
- if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
237
- pool.state.resetReasons.set(targetRef, 'header');
238
- if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
239
- pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
240
- _b = dynamicCooldownMs;
241
- pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
242
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
243
-
244
- const recTimer = setTimeout(() => {
245
- if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
246
- pool.state.failedUntil.delete(targetRef);
247
- pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
248
- }
249
- }, dynamicCooldownMs + 50);
250
- if (typeof recTimer.unref === 'function') recTimer.unref();
251
- } else {
252
- const _effBase = _base;
253
- _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
254
- if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
255
- pool.state.resetReasons.set(targetRef, 'backoff');
256
- }
257
-
258
- // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
259
- // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
260
- // must NOT be locked until midnight. Keep those on ordinary cooldowns.
261
- const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
262
- (/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
263
-
264
- const isQuota = !isRateThrottling && (
265
- (errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
266
- /\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
267
- );
268
- const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
269
- if (isQuota && qWindow) {
270
- const quotaResetAt = poolResetAt(pool, qWindow, now());
271
- if (quotaResetAt && quotaResetAt > now()) {
272
- if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
273
- pool.state.quotaWindows.set(targetRef, quotaResetAt);
274
- pool.state.failedUntil.set(targetRef, quotaResetAt);
275
- }
276
- }
277
-
278
- activePermit?.failure();
279
- trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
280
- pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
281
- if (typeof schedulePersist === 'function') {
282
- try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
283
- }
284
- if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
285
- const total = pool.refs?.length || 1;
286
- let available = 0;
287
- const at = now();
288
- for (const r of (pool.refs || [])) {
289
- if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
290
- available++;
291
- }
292
- }
293
- const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
294
- if (total > 0 && (available / total) <= threshold) {
295
- effectiveNotifyEarlyWarning(runtime0, pool, {
296
- provider: options.provider,
297
- availableCount: available,
298
- totalCount: total,
299
- });
300
- }
301
- }
302
- if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
303
- if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
304
- const _cStr = String(errCode ?? '').toUpperCase();
305
- const _isAuth = cls.reason === 'auth' || cls.code === '401' || cls.code === '403' ||
306
- _cStr === 'AUTH' || _cStr === '401' || _cStr === '403' ||
307
- _cStr === 'PERMISSION_DENIED' || _cStr === 'UNAUTHENTICATED' ||
308
- /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated|permission[_\s-]?denied/i.test(errMsg);
309
-
310
- if (_isAuth) {
311
- const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
312
- pool.state.authFailCounts.set(targetRef, _c2);
313
- if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
314
- pool.state.revokedRefs.add(targetRef);
315
- if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
316
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(targetRef);
317
- else if (Array.isArray(pool.state.pausedRefs) && !pool.state.pausedRefs.includes(targetRef)) pool.state.pausedRefs.push(targetRef);
318
- if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
319
- pool.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
320
- pool.state.failedUntil.set(targetRef, Infinity);
321
- pushEvent(pool, targetRef, 'KEY_AUTH_PAUSED', 0);
322
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently PAUSED on auth failure (${errCode})`);
323
- // Fan-out permanent pause across all pools sharing this physical ref (#408):
324
- const rAll = buildRuntime();
325
- const candidatePools = [
326
- ...(Array.isArray(rAll.pools) ? rAll.pools : []),
327
- ...(rAll.index?.allPools ? rAll.index.allPools.values() : []),
328
- ...(rAll.providerToPool ? rAll.providerToPool.values() : []),
329
- ];
330
- if (rAll.modelPoolByProvider) {
331
- for (const map of rAll.modelPoolByProvider.values()) { if (map) candidatePools.push(...map.values()); }
332
- }
333
- const seenPools = new Set();
334
- for (const other of candidatePools) {
335
- if (!other || seenPools.has(other)) continue;
336
- seenPools.add(other);
337
- if (other === pool) continue;
338
- const hasRef = (Array.isArray(other.refs) && other.refs.includes(targetRef)) ||
339
- (other.weightsMap instanceof Map && other.weightsMap.has(targetRef));
340
- if (hasRef && other.state) {
341
- if (!other.state.revokedRefs) other.state.revokedRefs = new Set();
342
- other.state.revokedRefs.add(targetRef);
343
- if (!other.state.pausedRefs) other.state.pausedRefs = new Set();
344
- if (other.state.pausedRefs instanceof Set) other.state.pausedRefs.add(targetRef);
345
- else if (Array.isArray(other.state.pausedRefs) && !other.state.pausedRefs.includes(targetRef)) other.state.pausedRefs.push(targetRef);
346
- if (!other.state.pauseReasons) other.state.pauseReasons = new Map();
347
- other.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
348
- if (other.state.failedUntil) other.state.failedUntil.set(targetRef, Infinity);
349
- pushEvent(other, targetRef, 'KEY_AUTH_PAUSED', 0);
350
- }
351
- }
352
- } else {
353
- pool.state.authFailCounts.delete(targetRef);
354
- // Soft/transient errors (TIMEOUT, 5xx, socket) do not count toward quarantine (#473).
355
- // recordFailure already maintains pool.state.failCounts accurately for non-soft failures.
356
- if (!isQuota && !cls.soft) {
357
- const _fc = pool.state.failCounts.get(targetRef) ?? 0;
358
- if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
359
- const quarantineMs = 1800000;
360
- pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
361
- pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
362
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
363
- }
364
- }
365
- }
206
+ penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
207
+ pool,
208
+ options,
209
+ sessionId,
210
+ runtime0,
211
+ cooldownMs,
212
+ maxCooldownMs,
213
+ now,
214
+ activePermit,
215
+ trace,
216
+ pushEvent,
217
+ schedulePersist,
218
+ effectiveNotifyEarlyWarning,
219
+ logWarn,
220
+ buildRuntime,
221
+ });
366
222
  };
367
223
 
368
224
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
@@ -420,13 +276,23 @@ export function createRotate(deps) {
420
276
  const curCandidate = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
421
277
  trace.addStep(curCandidate, 'DISPATCHED', 'ATTEMPT', `Attempt ${budget.getAttempts()}`);
422
278
 
423
- // mark the internal dispatch so the interceptor does not re-rotate
279
+ // #526: Direct key resolution without relying on credentials.resolve monkey-patch
280
+ const directSecret = await resolveKeyDirectly(curCandidate, ctx);
281
+ const directOpts = { ...options, [MARKER]: true, signal: budget.signal, _rotationKeyRef: curCandidate };
282
+ if (directSecret) {
283
+ directOpts.apiKey = directSecret;
284
+ if (!directOpts.headers) directOpts.headers = {};
285
+ if (!directOpts.headers.Authorization && !directOpts.headers.authorization) {
286
+ directOpts.headers.Authorization = `Bearer ${directSecret}`;
287
+ }
288
+ }
289
+
424
290
  inner = dispatchStorage.run(reqStore, () => {
425
291
  const llm = ctx.get('llm');
426
292
  if (!llm || typeof llm.stream !== 'function') {
427
293
  throw new Error('dsh-key-rotation: llm service unavailable');
428
294
  }
429
- return llm.stream({ ...options, [MARKER]: true, signal: budget.signal });
295
+ return llm.stream(directOpts);
430
296
  });
431
297
  } catch (e) {
432
298
  if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.80",
3
+ "version": "0.8.81",
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": [