@goodandready/dsh-key-rotation 0.8.18 → 0.8.20
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 +14 -0
- package/lib/circuit-breaker.js +53 -12
- package/lib/client.js +26 -19
- package/lib/heal.js +12 -1
- package/lib/index.js +9 -18
- package/lib/lifecycle.js +19 -6
- package/lib/notify-events.js +2 -0
- package/lib/persistence.js +16 -12
- package/lib/pool-builder.js +2 -1
- package/lib/pool-state.js +24 -0
- package/lib/pool.js +3 -1
- package/lib/rotate.js +175 -151
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
+
## 0.8.20 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Settings card on current DSH** (#345): the client reads its settings namespace through `configForms` instead of the removed `settingsScope` service. The package manifest declares the settings and slots providers the card injects, so the fiber activates and the card mounts again.
|
|
10
|
+
|
|
11
|
+
## 0.8.19 - 2026-09-22
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Restored pool state hydration & telemetry preservation (GitHub PR #14)**: extracted `initializePoolState()` and `hasPoolActivity()` into `lib/pool-state.js`. `StatePersistence.restorePools()` now hydrates existing runtime pools in-place, ensuring transient fields like `events` array are never undefined. `pushEvent()` defensively guards `events` array so telemetry errors cannot throw `TypeError` and mask underlying provider errors as `TRANSPORT`.
|
|
15
|
+
- **Circuit breaker probe lifecycle & stream accounting (GitHub PR #15)**: added once-settled request permits with generation tokens in `CircuitBreaker`. Correctly counts the first half-open probe, checks admission before each key attempt, prevents late failures while open from extending cooldowns, and distinguishes error/aborted terminals from successes in `rotate()`.
|
|
16
|
+
- **Accurate quota failure classification & backoff overflow saturation (GitHub PR #16)**: narrowed `penalizeRef()` quota detection regex in `lib/rotate.js` to explicit quota-exhaustion phrases (`insufficient_quota`, `quota exhausted`, `You exceeded your current quota`), preventing ordinary rate limits (e.g. `rate limit exceeded`, `requests per minute exceeded`, `deadline exceeded`) from being held until midnight UTC. Replaced 32-bit signed integer shifts in `computeBackoff()` with `Math.pow(2, failCount - 1)`, preventing negative delay wrap-around on 32+ failures.
|
|
17
|
+
- **Quarantined key auto-unbreak isolation (GitHub PR #17)**: in `setupAutoUnbreakEffect()`, quarantined keys are now probed using the un-wrapped original credentials resolver handle, ensuring the exact broken key is tested rather than a healthy neighbor chosen by the rotation wrapper. Added concurrency lock and state-observation checks to prevent stale probes from clearing newer failures.
|
|
18
|
+
- **Client error message visibility & empty ref validation (GitHub Issue #13)**: `validateBeforeSave()` in `lib/client.js` now normalizes and extracts `vres.message ?? vres.error?.message` from pre-save validation failures, displaying the server-provided reason instead of the generic "validation failed" toast. `buildPoolItem()` in `lib/pool-builder.js` filters out empty, whitespace-only, and invalid ref names using `isValidRef()`.
|
|
19
|
+
|
|
6
20
|
## 0.8.18 - 2026-09-21
|
|
7
21
|
|
|
8
22
|
### Fixed
|
package/lib/circuit-breaker.js
CHANGED
|
@@ -11,18 +11,24 @@ export class CircuitBreaker {
|
|
|
11
11
|
* @param {{ threshold?: number, openMs?: number, halfOpenProbes?: number, now?: () => number }} opts
|
|
12
12
|
*/
|
|
13
13
|
constructor({ threshold = 5, openMs = 30_000, halfOpenProbes = 1, now = Date.now } = {}) {
|
|
14
|
-
this.threshold
|
|
15
|
-
this.openMs = Math.max(100, openMs | 0);
|
|
16
|
-
this.halfOpenProbes = Math.max(1, halfOpenProbes | 0);
|
|
14
|
+
this.configure({ threshold, openMs, halfOpenProbes });
|
|
17
15
|
this._now = now;
|
|
18
|
-
/** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number}>} */
|
|
16
|
+
/** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number, generation:number}>} */
|
|
19
17
|
this._st = new Map();
|
|
20
18
|
}
|
|
21
19
|
|
|
20
|
+
configure({ threshold = 5, openMs = 30_000, halfOpenProbes = 1 } = {}) {
|
|
21
|
+
const integer = (value, fallback, min) => Number.isFinite(value)
|
|
22
|
+
? Math.max(min, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(value))) : fallback;
|
|
23
|
+
this.threshold = integer(threshold, 5, 1);
|
|
24
|
+
this.openMs = integer(openMs, 30_000, 100);
|
|
25
|
+
this.halfOpenProbes = integer(halfOpenProbes, 1, 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
_entry(provider) {
|
|
23
29
|
let e = this._st.get(provider);
|
|
24
30
|
if (!e) {
|
|
25
|
-
e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0 };
|
|
31
|
+
e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0, generation: 0 };
|
|
26
32
|
this._st.set(provider, e);
|
|
27
33
|
}
|
|
28
34
|
return e;
|
|
@@ -36,9 +42,10 @@ export class CircuitBreaker {
|
|
|
36
42
|
if (now - e.openedAt >= this.openMs) {
|
|
37
43
|
e.state = BREAKER_HALF_OPEN;
|
|
38
44
|
e.probes = 0;
|
|
39
|
-
|
|
45
|
+
e.generation += 1;
|
|
46
|
+
} else {
|
|
47
|
+
return false;
|
|
40
48
|
}
|
|
41
|
-
return false;
|
|
42
49
|
}
|
|
43
50
|
if (e.state === BREAKER_HALF_OPEN) {
|
|
44
51
|
if (e.probes < this.halfOpenProbes) {
|
|
@@ -50,26 +57,58 @@ export class CircuitBreaker {
|
|
|
50
57
|
return true;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// A permit is settled exactly once. Late outcomes from an older circuit
|
|
61
|
+
// generation (or a removed provider) cannot close/reopen a newer circuit.
|
|
62
|
+
acquire(provider) {
|
|
63
|
+
if (!this.canRequest(provider)) return null;
|
|
64
|
+
const entry = this._entry(provider);
|
|
65
|
+
const generation = entry.generation;
|
|
66
|
+
const probing = entry.state === BREAKER_HALF_OPEN;
|
|
67
|
+
let settled = false;
|
|
68
|
+
const settle = (fn) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
if (this._st.get(provider) === entry && entry.generation === generation) fn();
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
success: () => settle(() => this.onSuccess(provider)),
|
|
75
|
+
failure: () => settle(() => this.onFailure(provider)),
|
|
76
|
+
release: () => settle(() => {
|
|
77
|
+
if (probing && entry.state === BREAKER_HALF_OPEN) {
|
|
78
|
+
entry.probes = Math.max(0, entry.probes - 1);
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
onSuccess(provider) {
|
|
54
85
|
const e = this._entry(provider);
|
|
86
|
+
if (e.state !== BREAKER_CLOSED) e.generation += 1;
|
|
55
87
|
e.fails = 0;
|
|
56
88
|
e.probes = 0;
|
|
89
|
+
e.openedAt = 0;
|
|
57
90
|
e.state = BREAKER_CLOSED;
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
onFailure(provider) {
|
|
61
94
|
const e = this._entry(provider);
|
|
62
95
|
const now = this._now();
|
|
96
|
+
// In-flight failures arriving after opening must not extend the cooldown.
|
|
97
|
+
if (e.state === BREAKER_OPEN) return e.state;
|
|
63
98
|
if (e.state === BREAKER_HALF_OPEN) {
|
|
64
99
|
e.state = BREAKER_OPEN;
|
|
65
100
|
e.openedAt = now;
|
|
66
101
|
e.fails = this.threshold;
|
|
102
|
+
e.probes = 0;
|
|
103
|
+
e.generation += 1;
|
|
67
104
|
return e.state;
|
|
68
105
|
}
|
|
69
106
|
e.fails += 1;
|
|
70
107
|
if (e.fails >= this.threshold) {
|
|
71
108
|
e.state = BREAKER_OPEN;
|
|
72
109
|
e.openedAt = now;
|
|
110
|
+
e.probes = 0;
|
|
111
|
+
e.generation += 1;
|
|
73
112
|
}
|
|
74
113
|
return e.state;
|
|
75
114
|
}
|
|
@@ -81,7 +120,7 @@ export class CircuitBreaker {
|
|
|
81
120
|
snapshot() {
|
|
82
121
|
const out = {};
|
|
83
122
|
for (const [k, e] of this._st) {
|
|
84
|
-
out[k] = { state: e.state, fails: e.fails, openedAt: e.
|
|
123
|
+
out[k] = { state: e.state, fails: e.fails, openedAt: e.state === BREAKER_CLOSED ? null : e.openedAt };
|
|
85
124
|
}
|
|
86
125
|
return out;
|
|
87
126
|
}
|
|
@@ -92,20 +131,22 @@ export class CircuitBreaker {
|
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
/** Restore entries from a persistence snapshot (#287). */
|
|
95
|
-
restore(snapshot) {
|
|
96
|
-
if (!snapshot || typeof snapshot !== 'object') return 0;
|
|
134
|
+
restore(snapshot, { preserveExisting = false } = {}) {
|
|
135
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return 0;
|
|
97
136
|
let n = 0;
|
|
98
137
|
for (const [provider, e] of Object.entries(snapshot)) {
|
|
99
|
-
if (!e || typeof e !== 'object') continue;
|
|
138
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
|
|
139
|
+
if (preserveExisting && this._st.has(provider)) continue;
|
|
100
140
|
const state = e.state === BREAKER_OPEN || e.state === BREAKER_HALF_OPEN ? e.state : BREAKER_CLOSED;
|
|
101
141
|
this._st.set(provider, {
|
|
102
142
|
state,
|
|
103
143
|
fails: Number.isFinite(e.fails) ? e.fails : 0,
|
|
104
144
|
openedAt: Number.isFinite(e.openedAt) ? e.openedAt : 0,
|
|
105
145
|
probes: 0,
|
|
146
|
+
generation: 0,
|
|
106
147
|
});
|
|
107
148
|
n += 1;
|
|
108
149
|
}
|
|
109
150
|
return n;
|
|
110
151
|
}
|
|
111
|
-
}
|
|
152
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -644,7 +644,7 @@ window.__ModuleLoader__.load({
|
|
|
644
644
|
);
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
// Provider registration is not part of
|
|
647
|
+
// Provider registration is not part of configForms's value. Fetch it
|
|
648
648
|
// independently, and never turn an unavailable catalog into proof of absence.
|
|
649
649
|
function useProviderCatalog() {
|
|
650
650
|
const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
|
|
@@ -687,10 +687,10 @@ window.__ModuleLoader__.load({
|
|
|
687
687
|
|
|
688
688
|
function KeyRotationSection(props) {
|
|
689
689
|
const t = resolveT(props);
|
|
690
|
-
const
|
|
690
|
+
const configForms = React.useMemo(() => {
|
|
691
691
|
try {
|
|
692
|
-
if (props.ctx && props.ctx.
|
|
693
|
-
return props.ctx.
|
|
692
|
+
if (props.ctx && props.ctx.configForms) {
|
|
693
|
+
return props.ctx.configForms.get(NS);
|
|
694
694
|
}
|
|
695
695
|
} catch (_) {
|
|
696
696
|
return null;
|
|
@@ -700,10 +700,10 @@ window.__ModuleLoader__.load({
|
|
|
700
700
|
// #273: getSnapshot must be referentially stable or React 18 unmounts the tree
|
|
701
701
|
const scopeCacheRef = React.useRef({ has: false, value: null });
|
|
702
702
|
const getScopeSnapshot = React.useCallback(() => {
|
|
703
|
-
if (!
|
|
703
|
+
if (!configForms || typeof configForms.getSnapshot !== 'function') return null;
|
|
704
704
|
let next = null;
|
|
705
705
|
try {
|
|
706
|
-
next =
|
|
706
|
+
next = configForms.getSnapshot();
|
|
707
707
|
} catch (_) {
|
|
708
708
|
return null;
|
|
709
709
|
}
|
|
@@ -718,21 +718,21 @@ window.__ModuleLoader__.load({
|
|
|
718
718
|
}
|
|
719
719
|
scopeCacheRef.current = { has: true, value: next };
|
|
720
720
|
return next;
|
|
721
|
-
}, [
|
|
721
|
+
}, [configForms]);
|
|
722
722
|
const scopeSnapshot = React.useSyncExternalStore(
|
|
723
|
-
React.useMemo(() => (cb) => (
|
|
723
|
+
React.useMemo(() => (cb) => (configForms && typeof configForms.subscribe === 'function' ? configForms.subscribe(cb) : () => {}), [configForms]),
|
|
724
724
|
getScopeSnapshot
|
|
725
725
|
);
|
|
726
726
|
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
727
727
|
const catalog = useProviderCatalog();
|
|
728
728
|
const latestScopeSnapshot = React.useRef(scopeSnapshot);
|
|
729
729
|
latestScopeSnapshot.current = scopeSnapshot;
|
|
730
|
-
// #290: surface
|
|
730
|
+
// #290: surface configForms availability as card state
|
|
731
731
|
React.useEffect(() => {
|
|
732
|
-
if (!
|
|
732
|
+
if (!configForms) {
|
|
733
733
|
setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
|
|
734
734
|
}
|
|
735
|
-
}, [
|
|
735
|
+
}, [configForms]);
|
|
736
736
|
const [draft, setDraft] = React.useState(null);
|
|
737
737
|
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
738
738
|
const [search, setSearch] = React.useState('');
|
|
@@ -947,9 +947,15 @@ window.__ModuleLoader__.load({
|
|
|
947
947
|
headers: { 'content-type': 'application/json' },
|
|
948
948
|
body: JSON.stringify({ ref, value }),
|
|
949
949
|
})
|
|
950
|
-
.then((r) =>
|
|
951
|
-
|
|
952
|
-
|
|
950
|
+
.then(async (r) => {
|
|
951
|
+
const data = (await r.json().catch(() => null)) ?? {};
|
|
952
|
+
const resData = !r.ok && data.error
|
|
953
|
+
? { ok: false, code: data.error.code, message: data.error.message }
|
|
954
|
+
: { ok: r.ok, ...data };
|
|
955
|
+
setValidationResult((m) => ({ ...m, [ref]: resData }));
|
|
956
|
+
return resData;
|
|
957
|
+
})
|
|
958
|
+
.catch((e) => ({ ok: false, message: String(e?.message ?? e) }))
|
|
953
959
|
.finally(() => setValidating(''));
|
|
954
960
|
};
|
|
955
961
|
const saveSecret = async (ref, rowKey) => {
|
|
@@ -963,7 +969,8 @@ window.__ModuleLoader__.load({
|
|
|
963
969
|
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
964
970
|
// No credential yet is fine for a new key being saved
|
|
965
971
|
} else if (vres && !vres.ok) {
|
|
966
|
-
|
|
972
|
+
const msg = vres.message ?? vres.error?.message ?? 'validation failed';
|
|
973
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', msg));
|
|
967
974
|
return;
|
|
968
975
|
}
|
|
969
976
|
fetch('/dsh-key-rotation/key', {
|
|
@@ -1007,7 +1014,7 @@ window.__ModuleLoader__.load({
|
|
|
1007
1014
|
React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
|
|
1008
1015
|
);
|
|
1009
1016
|
}
|
|
1010
|
-
if (state.status === 'unavailable' || !
|
|
1017
|
+
if (state.status === 'unavailable' || !configForms) {
|
|
1011
1018
|
return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
1012
1019
|
React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
|
|
1013
1020
|
React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
|
|
@@ -1120,11 +1127,11 @@ window.__ModuleLoader__.load({
|
|
|
1120
1127
|
})
|
|
1121
1128
|
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
1122
1129
|
};
|
|
1123
|
-
if (
|
|
1130
|
+
if (configForms && typeof configForms.set === 'function') {
|
|
1124
1131
|
(async () => {
|
|
1125
1132
|
try {
|
|
1126
1133
|
for (const [k, v] of Object.entries(draft)) {
|
|
1127
|
-
await
|
|
1134
|
+
await configForms.set(k, v);
|
|
1128
1135
|
}
|
|
1129
1136
|
setState((s) => ({ ...s, status: 'ready', value: draft, error: '' }));
|
|
1130
1137
|
setDraft(null);
|
|
@@ -1929,7 +1936,7 @@ window.__ModuleLoader__.load({
|
|
|
1929
1936
|
}
|
|
1930
1937
|
}
|
|
1931
1938
|
|
|
1932
|
-
module.exports = { apply, inject: ['slots', 'locale', '
|
|
1939
|
+
module.exports = { apply, inject: ['slots', 'locale', 'configForms'] };
|
|
1933
1940
|
return module.exports;
|
|
1934
1941
|
},
|
|
1935
1942
|
updateCheck: 'Check for updates',
|
package/lib/heal.js
CHANGED
|
@@ -42,9 +42,10 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
|
42
42
|
* @param {Array} pools
|
|
43
43
|
* @param {Function} probeFn async (ref) => { ok: boolean, code?: string, status?: number }
|
|
44
44
|
* @param {number} [now=Date.now()]
|
|
45
|
+
* @param {{ isActive?: () => boolean }} [options] lifecycle guard for delayed results
|
|
45
46
|
* @returns {Promise<Array<{ ref: string, poolBase: string, ok: boolean }>>}
|
|
46
47
|
*/
|
|
47
|
-
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
48
|
+
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), { isActive = () => true } = {}) {
|
|
48
49
|
if (!Array.isArray(pools) || pools.length === 0 || typeof probeFn !== 'function') return [];
|
|
49
50
|
const results = [];
|
|
50
51
|
for (const pool of pools) {
|
|
@@ -58,8 +59,18 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
for (const ref of brokenRefs) {
|
|
62
|
+
if (!isActive()) return results;
|
|
63
|
+
const state = pool.state;
|
|
64
|
+
const fields = ['brokenUntil', 'failedUntil', 'failCounts', 'authFailCounts'];
|
|
65
|
+
const observed = fields.map(field => [field, state[field], state[field]?.get(ref)]);
|
|
66
|
+
// An earlier await may have removed this key from the captured worklist.
|
|
67
|
+
if (state.brokenUntil !== bu || !bu.has(ref)) continue;
|
|
61
68
|
try {
|
|
62
69
|
const res = await probeFn(ref);
|
|
70
|
+
if (!isActive()) return results;
|
|
71
|
+
// Do not let a late success erase a newer failure or replaced state.
|
|
72
|
+
if (pool.state !== state || observed.some(([field, map, value]) =>
|
|
73
|
+
state[field] !== map || map?.get(ref) !== value)) continue;
|
|
63
74
|
if (res && res.ok) {
|
|
64
75
|
bu.delete(ref);
|
|
65
76
|
pool.state.failedUntil?.delete(ref);
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
|
22
22
|
import { compactUsage } from './usage-report.js';
|
|
23
23
|
import { json, handleConfigBridge } from './http-bridge.js';
|
|
24
24
|
import { createRotate } from './rotate.js';
|
|
25
|
+
import { initializePoolState } from './pool-state.js';
|
|
25
26
|
import { nowMono } from './clock.js';
|
|
26
27
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
27
28
|
import { NotifyQueue } from './notify-queue.js';
|
|
@@ -219,6 +220,11 @@ export function apply(ctx, config = {}) {
|
|
|
219
220
|
lastConfigRef = cfg;
|
|
220
221
|
lastProfilesRef = profilesSnapshot;
|
|
221
222
|
|
|
223
|
+
moduleBreaker.configure({
|
|
224
|
+
threshold: cfg.circuitBreakerThreshold ?? 5,
|
|
225
|
+
openMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
226
|
+
halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
|
|
227
|
+
});
|
|
222
228
|
const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
|
|
223
229
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
224
230
|
const maxCooldownMs = cfg.maxCooldownMs;
|
|
@@ -239,25 +245,10 @@ export function apply(ctx, config = {}) {
|
|
|
239
245
|
const makeState = (base) => {
|
|
240
246
|
let st = poolState.get(base);
|
|
241
247
|
if (!st) {
|
|
242
|
-
st =
|
|
243
|
-
failedUntil: new Map(),
|
|
244
|
-
failCounts: new Map(),
|
|
245
|
-
authFailCounts: new Map(),
|
|
246
|
-
brokenUntil: new Map(),
|
|
247
|
-
costPerKey: new Map(),
|
|
248
|
-
lastUsedAt: new Map(),
|
|
249
|
-
usageCounts: new Map(),
|
|
250
|
-
byModel: new Map(),
|
|
251
|
-
usageDays: new Map(),
|
|
252
|
-
quotaWindows: new Map(),
|
|
253
|
-
costDays: new Map(),
|
|
254
|
-
events: [],
|
|
255
|
-
pointer: 0,
|
|
256
|
-
lastUsed: null,
|
|
257
|
-
};
|
|
248
|
+
st = initializePoolState();
|
|
258
249
|
poolState.set(base, st);
|
|
259
250
|
}
|
|
260
|
-
return st;
|
|
251
|
+
return initializePoolState(st);
|
|
261
252
|
};
|
|
262
253
|
|
|
263
254
|
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
@@ -529,7 +520,7 @@ export function apply(ctx, config = {}) {
|
|
|
529
520
|
const cls = classifyFailure(payload);
|
|
530
521
|
const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
|
|
531
522
|
if (!switchable) return next();
|
|
532
|
-
if (moduleBreaker) moduleBreaker.onFailure(provider);
|
|
523
|
+
if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
|
|
533
524
|
const ref = pool.state.lastUsed;
|
|
534
525
|
if (ref) {
|
|
535
526
|
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft, true);
|
package/lib/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
3
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { envValue } from './pool.js';
|
|
5
6
|
|
|
6
7
|
export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
|
|
7
8
|
return ctx.effect(() => {
|
|
@@ -30,21 +31,33 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
30
31
|
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
31
32
|
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
32
33
|
const intervalMs = intervalMin * 60 * 1000;
|
|
34
|
+
let running = false;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
const isActive = () => !disposed && (getConfig()?.selfHealingIntervalMinutes ?? 0) > 0;
|
|
33
37
|
const timer = setInterval(async () => {
|
|
38
|
+
if (running || disposed) return;
|
|
39
|
+
running = true;
|
|
34
40
|
try {
|
|
35
41
|
const c = getConfig();
|
|
36
42
|
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
37
43
|
const { pools } = buildRuntime();
|
|
38
44
|
const runner = ensureSandboxRunner(ctx);
|
|
39
45
|
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// A rotating resolver can substitute a healthy neighbor for this
|
|
47
|
+
// quarantined ref, falsely proving that the broken key recovered.
|
|
48
|
+
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
49
|
+
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
50
|
+
const hit = await resolve?.call(credentials, ref);
|
|
51
|
+
const value = typeof hit?.value === 'string' && hit.value.length > 0
|
|
52
|
+
? hit.value : envValue(ref);
|
|
53
|
+
if (!value || !isActive()) return { ok: false };
|
|
54
|
+
return runner.probeModels(ref, value);
|
|
55
|
+
}, Date.now(), { isActive });
|
|
44
56
|
} catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
57
|
+
finally { running = false; }
|
|
45
58
|
}, intervalMs);
|
|
46
59
|
if (typeof timer.unref === 'function') timer.unref();
|
|
47
|
-
return () => clearInterval(timer);
|
|
60
|
+
return () => { disposed = true; clearInterval(timer); };
|
|
48
61
|
}, 'dsh-key-rotation: auto-unbreak');
|
|
49
62
|
}
|
|
50
63
|
|
|
@@ -65,7 +78,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
65
78
|
if (!snap) return;
|
|
66
79
|
try {
|
|
67
80
|
StatePersistence.restorePools(poolState, snap);
|
|
68
|
-
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
|
|
81
|
+
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit, { preserveExisting: true });
|
|
69
82
|
if (verboseLoggingOn?.()) {
|
|
70
83
|
logger?.warn?.(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
|
|
71
84
|
}
|
package/lib/notify-events.js
CHANGED
|
@@ -32,6 +32,8 @@ export function notifySwitch(runtime, pool, info, hooks = { webhookSender: defau
|
|
|
32
32
|
|
|
33
33
|
export function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
34
34
|
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
35
|
+
// Legacy/partially restored state must not replace the provider's error.
|
|
36
|
+
if (!Array.isArray(pool.state.events)) pool.state.events = [];
|
|
35
37
|
pool.state.events.push(ev);
|
|
36
38
|
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
37
39
|
}
|
package/lib/persistence.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// reload would otherwise forget cooldowns and stampede recovered keys.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { initializePoolState, hasPoolActivity } from './pool-state.js';
|
|
6
7
|
import { atomicWriteJson, safeReadJson } from './atomic-io.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_FILE = 'dsh-key-rotation-state.json';
|
|
@@ -100,21 +101,24 @@ export class StatePersistence {
|
|
|
100
101
|
|
|
101
102
|
/** Restore poolState Map from a loaded snapshot. */
|
|
102
103
|
static restorePools(poolState, snapshot) {
|
|
103
|
-
if (!poolState || !snapshot
|
|
104
|
+
if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
|
|
104
105
|
let n = 0;
|
|
105
|
-
for (const [base,
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
for (const [base, saved] of Object.entries(snapshot.pools)) {
|
|
107
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
|
|
108
|
+
const existing = poolState.get(base);
|
|
109
|
+
const state = initializePoolState(existing);
|
|
110
|
+
// Never replace objects retained by buildRuntime(), nor roll back a pool
|
|
111
|
+
// already used while the asynchronous load was in flight.
|
|
112
|
+
if (!existing || !hasPoolActivity(state)) {
|
|
113
|
+
if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
|
|
114
|
+
for (const [ref, until] of Object.entries(saved.failedUntil)) {
|
|
115
|
+
if (Number.isFinite(until)) state.failedUntil.set(ref, until);
|
|
116
|
+
}
|
|
111
117
|
}
|
|
118
|
+
state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
|
|
119
|
+
state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
|
|
112
120
|
}
|
|
113
|
-
poolState.set(base,
|
|
114
|
-
failedUntil,
|
|
115
|
-
pointer: Number.isFinite(st.pointer) ? st.pointer : 0,
|
|
116
|
-
lastUsed: typeof st.lastUsed === 'string' ? st.lastUsed : undefined,
|
|
117
|
-
});
|
|
121
|
+
poolState.set(base, state);
|
|
118
122
|
n += 1;
|
|
119
123
|
}
|
|
120
124
|
return n;
|
package/lib/pool-builder.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// lib/pool-builder.js — provider & per-model pool assembly and cleanup
|
|
2
2
|
import { bucketSweep } from './bucket.js';
|
|
3
|
+
import { isValidRef } from './pool.js';
|
|
3
4
|
|
|
4
5
|
export function parseExpiry(v) {
|
|
5
6
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
@@ -22,7 +23,7 @@ export function buildPoolItem({
|
|
|
22
23
|
rpmLimit,
|
|
23
24
|
makeState,
|
|
24
25
|
}) {
|
|
25
|
-
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.
|
|
26
|
+
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && isValidRef(ref.trim()));
|
|
26
27
|
if (refs.length === 0) return null;
|
|
27
28
|
const w = Array.isArray(weights) ? weights : [];
|
|
28
29
|
const weightedRefs = [];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared runtime shape for fresh and persisted pools. Hydrate in place so cached
|
|
2
|
+
// runtime pools, persistence and timers continue to observe the same object.
|
|
3
|
+
const MAP_FIELDS = [
|
|
4
|
+
'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
|
|
5
|
+
'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function initializePoolState(state = {}) {
|
|
9
|
+
for (const key of MAP_FIELDS) {
|
|
10
|
+
if (!(state[key] instanceof Map)) state[key] = new Map();
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(state.events)) state.events = [];
|
|
13
|
+
if (!Number.isSafeInteger(state.pointer) || state.pointer < 0) state.pointer = 0;
|
|
14
|
+
if (typeof state.lastUsed !== 'string') state.lastUsed = null;
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Startup disk I/O can finish after requests have already used a pool. In that
|
|
19
|
+
// case the live state wins: replaying an older cooldown/cursor can undo success.
|
|
20
|
+
export function hasPoolActivity(state) {
|
|
21
|
+
return state.pointer !== 0 || state.lastUsed !== null || state.events.length > 0 ||
|
|
22
|
+
MAP_FIELDS.some((key) => state[key].size > 0) ||
|
|
23
|
+
(state.lastSuccessAt?.size ?? 0) > 0;
|
|
24
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -122,7 +122,9 @@ export function computeBackoff(baseMs, failCount, maxMs, isSoft = false) {
|
|
|
122
122
|
}
|
|
123
123
|
const cap = maxMs ?? baseMs * 8;
|
|
124
124
|
if (failCount <= 1) return Math.min(baseMs, cap);
|
|
125
|
-
|
|
125
|
+
// Bit shifts wrap/sign-flip at 32 failures. Floating-point exponentiation
|
|
126
|
+
// saturates at Infinity, which the configured cap safely bounds.
|
|
127
|
+
const backoff = baseMs === 0 ? 0 : baseMs * (2 ** (failCount - 1));
|
|
126
128
|
return Math.min(backoff, cap);
|
|
127
129
|
}
|
|
128
130
|
|
package/lib/rotate.js
CHANGED
|
@@ -47,6 +47,8 @@ export function createRotate(deps) {
|
|
|
47
47
|
const runtime0 = buildRuntime();
|
|
48
48
|
const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
|
|
49
49
|
let lastFailure = null;
|
|
50
|
+
const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
|
|
51
|
+
let activePermit = null;
|
|
50
52
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
51
53
|
setRotateStartMs(reqStore.startMs);
|
|
52
54
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
@@ -82,7 +84,10 @@ export function createRotate(deps) {
|
|
|
82
84
|
const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
|
|
83
85
|
|
|
84
86
|
// #337: connect quota-window calendar reset for quota exhaustion
|
|
85
|
-
|
|
87
|
+
// "rate limit exceeded" and "deadline exceeded" are not evidence
|
|
88
|
+
// of an exhausted billing quota. Keep those on ordinary cooldowns.
|
|
89
|
+
const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' ||
|
|
90
|
+
/\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
|
|
86
91
|
const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
|
|
87
92
|
if (isQuota && qWindow) {
|
|
88
93
|
const quotaResetAt = nextQuotaReset(qWindow, now());
|
|
@@ -93,7 +98,7 @@ export function createRotate(deps) {
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
activePermit?.failure();
|
|
97
102
|
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
98
103
|
if (typeof schedulePersist === 'function') {
|
|
99
104
|
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
@@ -113,173 +118,192 @@ export function createRotate(deps) {
|
|
|
113
118
|
}
|
|
114
119
|
};
|
|
115
120
|
|
|
116
|
-
// #260: fail fast when provider circuit is open
|
|
117
|
-
if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
|
|
118
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
119
|
-
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
121
|
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
inner = dispatchStorage.run(reqStore, () => {
|
|
130
|
-
const llm = ctx.get('llm');
|
|
131
|
-
if (!llm || typeof llm.stream !== 'function') {
|
|
132
|
-
throw new Error('dsh-key-rotation: llm service unavailable');
|
|
133
|
-
}
|
|
134
|
-
return llm.stream({ ...options, [MARKER]: true });
|
|
135
|
-
});
|
|
136
|
-
} catch (e) {
|
|
137
|
-
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
138
|
-
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
139
|
-
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
140
|
-
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
141
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
142
|
-
continue;
|
|
122
|
+
activePermit = breaker ? breaker.acquire(options.provider) : null;
|
|
123
|
+
if (breaker && !activePermit) {
|
|
124
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
125
|
+
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
126
|
+
return;
|
|
143
127
|
}
|
|
144
|
-
|
|
145
|
-
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
146
|
-
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
147
128
|
try {
|
|
129
|
+
let yielded = false;
|
|
130
|
+
let switching = false;
|
|
131
|
+
let inner;
|
|
148
132
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
yield chunk;
|
|
155
|
-
continue;
|
|
133
|
+
// mark the internal dispatch so the interceptor does not re-rotate
|
|
134
|
+
inner = dispatchStorage.run(reqStore, () => {
|
|
135
|
+
const llm = ctx.get('llm');
|
|
136
|
+
if (!llm || typeof llm.stream !== 'function') {
|
|
137
|
+
throw new Error('dsh-key-rotation: llm service unavailable');
|
|
156
138
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
switching = true;
|
|
182
|
-
break;
|
|
139
|
+
return llm.stream({ ...options, [MARKER]: true });
|
|
140
|
+
});
|
|
141
|
+
} catch (e) {
|
|
142
|
+
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
143
|
+
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
144
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
145
|
+
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
146
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
151
|
+
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
152
|
+
try {
|
|
153
|
+
try {
|
|
154
|
+
for await (const chunk of inner) {
|
|
155
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
156
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
157
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
158
|
+
yielded = true;
|
|
159
|
+
yield chunk;
|
|
160
|
+
continue;
|
|
183
161
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
pool.state.
|
|
162
|
+
if (chunk && chunk.type === 'finish') {
|
|
163
|
+
const kind = chunk.reason?.kind;
|
|
164
|
+
const failure = chunk.reason?.failure;
|
|
165
|
+
const code = failure?.code;
|
|
166
|
+
const message = failure?.message ?? '';
|
|
167
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
168
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
169
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
170
|
+
if (switchable) {
|
|
171
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
172
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
173
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
174
|
+
pool.state.lastSwitchAt = now();
|
|
175
|
+
lastFailure = chunk;
|
|
176
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
177
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
178
|
+
if (switchNotify && activeRef) {
|
|
179
|
+
notifySwitch(runtime0, pool, {
|
|
180
|
+
provider: options.provider,
|
|
181
|
+
from: activeRef,
|
|
182
|
+
code: String(code ?? 'UNKNOWN'),
|
|
183
|
+
at: pool.state.lastSwitchAt,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
switching = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// Error/abort terminals are not successful responses. After a
|
|
190
|
+
// content delta we may penalize, but must never replay the request.
|
|
191
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
192
|
+
if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
|
|
193
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
194
|
+
}
|
|
195
|
+
yield chunk;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// cost tracking if provider returns usage.cost
|
|
199
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
200
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
201
|
+
const c = Number(chunk.usage.cost);
|
|
202
|
+
if (!isNaN(c)) {
|
|
203
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
204
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
205
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
206
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
207
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
208
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
209
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Usage by day (#119)
|
|
213
|
+
if (activeRef) {
|
|
214
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
215
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
216
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
217
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
218
|
+
}
|
|
219
|
+
// Per-model request detail (#121)
|
|
220
|
+
if (activeRef && options.model) {
|
|
221
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
222
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
223
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
224
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
// #336: record success for the active key upon successful stream finish
|
|
227
|
+
if (activeRef) {
|
|
228
|
+
recordSuccess(pool, activeRef, now());
|
|
196
229
|
}
|
|
197
|
-
}
|
|
198
|
-
// Usage by day (#119)
|
|
199
|
-
if (activeRef) {
|
|
200
|
-
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
201
|
-
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
202
|
-
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
203
|
-
pool.state.usageDays.set(activeRef, dayMap);
|
|
204
|
-
}
|
|
205
|
-
// Per-model request detail (#121)
|
|
206
|
-
if (activeRef && options.model) {
|
|
207
|
-
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
208
|
-
let byRef = pool.state.byModel.get(activeRef);
|
|
209
|
-
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
210
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
211
|
-
}
|
|
212
|
-
// #336: record success for the active key upon successful stream finish
|
|
213
|
-
if (activeRef) {
|
|
214
|
-
recordSuccess(pool, activeRef, now());
|
|
215
|
-
}
|
|
216
230
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
232
|
+
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
233
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
234
|
+
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
235
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
236
|
+
if (guardEnabled && rate && activeRef) {
|
|
237
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
238
|
+
let cool;
|
|
239
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
240
|
+
cool = rate.retryAfter * 1000;
|
|
241
|
+
} else if (rate.reset && rate.reset > now()) {
|
|
242
|
+
cool = rate.reset - now();
|
|
243
|
+
} else {
|
|
244
|
+
cool = pool.cooldownMs ?? cooldownMs;
|
|
245
|
+
}
|
|
246
|
+
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
247
|
+
const effCool = Math.min(cool, maxCool ?? cool);
|
|
248
|
+
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
249
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
250
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
231
251
|
}
|
|
232
|
-
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
233
|
-
const effCool = Math.min(cool, maxCool ?? cool);
|
|
234
|
-
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
235
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
236
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
237
252
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
254
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
255
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
256
|
+
}
|
|
257
|
+
recordLatency(pool, reqStore);
|
|
258
|
+
activePermit?.success();
|
|
259
|
+
yield chunk;
|
|
260
|
+
return;
|
|
242
261
|
}
|
|
243
262
|
yield chunk;
|
|
244
|
-
recordLatency(pool, reqStore);
|
|
245
|
-
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
246
|
-
return;
|
|
247
263
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
264
|
+
} catch (e) {
|
|
265
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
266
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
267
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
268
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
269
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
270
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
271
|
+
pool.state.lastSwitchAt = now();
|
|
272
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
273
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
274
|
+
if (switchNotify && activeRef) {
|
|
275
|
+
notifySwitch(runtime0, pool, {
|
|
276
|
+
provider: options.provider,
|
|
277
|
+
from: activeRef,
|
|
278
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
279
|
+
at: pool.state.lastSwitchAt,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
continue; // Failover to next key!
|
|
283
|
+
}
|
|
284
|
+
if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
285
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
267
286
|
}
|
|
268
|
-
|
|
287
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
288
|
+
return;
|
|
269
289
|
}
|
|
270
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
290
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
291
|
+
if (switching) continue; // try the next key
|
|
292
|
+
if (yielded) {
|
|
293
|
+
const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
|
|
294
|
+
if (activeRef) recordSuccess(pool, activeRef, now());
|
|
295
|
+
recordLatency(pool, reqStore);
|
|
296
|
+
activePermit?.success();
|
|
297
|
+
}
|
|
298
|
+
return; // clean end — served
|
|
299
|
+
} finally {
|
|
300
|
+
if (acquired && _pickedRef) {
|
|
301
|
+
concurrencyTracker.release(_pickedRef);
|
|
302
|
+
}
|
|
277
303
|
}
|
|
278
|
-
return; // clean end — served
|
|
279
304
|
} finally {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
305
|
+
// Also runs on consumer return(), abort, empty EOF and local errors.
|
|
306
|
+
activePermit?.release();
|
|
283
307
|
}
|
|
284
308
|
}
|
|
285
309
|
|
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.20",
|
|
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": [
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"platform": "web",
|
|
47
47
|
"inject": [
|
|
48
48
|
"@deepseek-ai/dsh-client-locale",
|
|
49
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
50
51
|
]
|
|
51
52
|
}
|
|
52
53
|
},
|