@goodandready/dsh-key-rotation 0.7.26 → 0.7.28

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.
@@ -0,0 +1,31 @@
1
+ // lib/keycheck.js - token leak detector (#200).
2
+ // Recognizes live API-key shapes so a real secret pasted into a wrong field
3
+ // (webhook URL, provider name, ...) is caught before it leaves the machine.
4
+ const PATTERNS = [
5
+ { type: 'anthropic', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
6
+ { type: 'openai', re: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/ },
7
+ { type: 'google', re: /\bAIza[0-9A-Za-z_-]{30,}\b/ },
8
+ { type: 'github', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
9
+ { type: 'aws', re: /\bAKIA[0-9A-Z]{16}\b/ },
10
+ { type: 'slack', re: /\bxox[bpars]-[A-Za-z0-9-]{10,}\b/ },
11
+ { type: 'telegram', re: /\b\d{8,10}:AA[A-Za-z0-9_-]{30,}\b/ },
12
+ { type: 'stripe', re: /\b(sk|pk)_(live|test)_[A-Za-z0-9]{20,}\b/ },
13
+ { type: 'private-key', re: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
14
+ ];
15
+
16
+ /** All secrets found in `text`: [{ type, index, preview }]. Preview = first 8 chars only. */
17
+ export function findSecrets(text) {
18
+ const out = [];
19
+ const s = String(text ?? '');
20
+ for (const { type, re } of PATTERNS) {
21
+ for (const m of s.matchAll(new RegExp(re.source, 'g'))) {
22
+ out.push({ type, index: m.index, preview: m[0].slice(0, 8) + '…' });
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** True when the value itself looks like a live credential (for PUT /key sanity hint). */
29
+ export function looksLikeApiSecret(value) {
30
+ return findSecrets(value).length > 0;
31
+ }
@@ -0,0 +1,64 @@
1
+ // lib/maintenance.js - pure helpers for #207 (expiry pre-warning) and
2
+ // #208 (cost budget). No I/O; the timers in index.js call these and decide
3
+ // whether to send webhooks.
4
+
5
+ const DAY_MS = 86400000;
6
+
7
+ /**
8
+ * #207: keys of `pool` whose expiresAt falls within the next `warnDays`.
9
+ * Returns [{ ref, expiresInDays, expiresAt }], soonest first.
10
+ */
11
+ export function expiringSoon(pool, warnDays, now = Date.now()) {
12
+ if (!pool || !pool.expiresAt) return [];
13
+ const horizon = now + Math.max(1, warnDays ?? 7) * DAY_MS;
14
+ return Object.entries(pool.expiresAt)
15
+ .filter(([, at]) => at > now && at <= horizon)
16
+ .map(([ref, at]) => ({ ref, expiresAt: at, expiresInDays: Math.max(0, Math.floor((at - now) / DAY_MS)) }))
17
+ .sort((a, b) => a.expiresAt - b.expiresAt);
18
+ }
19
+
20
+ /** #207 dedupe: true when a day-level notification for `key` is due. */
21
+ export function shouldNotifyDaily(lastNotified, key, now = Date.now()) {
22
+ if (!lastNotified.has(key)) {
23
+ lastNotified.set(key, now);
24
+ return true;
25
+ }
26
+ const last = lastNotified.get(key);
27
+ if (now - last < DAY_MS) return false;
28
+ lastNotified.set(key, now);
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * #208: total spend of a pool on ISO day `day` (defaults to today)
34
+ * across costDays Map<ref, Map<day, cost>>.
35
+ */
36
+ export function costForDay(costDays, day) {
37
+ const d = day ?? new Date().toISOString().slice(0, 10);
38
+ let total = 0;
39
+ for (const perRef of (costDays?.values() ?? [])) {
40
+ total += perRef.get(d) ?? 0;
41
+ }
42
+ return total;
43
+ }
44
+
45
+ /** #208: total spend over the last 7 ISO days ending today. */
46
+ export function costForWeek(costDays, now = Date.now()) {
47
+ let total = 0;
48
+ for (let i = 0; i < 7; i++) {
49
+ const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
50
+ total += costForDay(costDays, d);
51
+ }
52
+ return total;
53
+ }
54
+
55
+ /**
56
+ * #208 budget verdict for a pool: { spend, budget, ratio, warn, exceeded }.
57
+ * budget <= 0 -> never warn.
58
+ */
59
+ export function budgetVerdict(spend, budget) {
60
+ if (!budget || budget <= 0) return { spend, budget: 0, ratio: 0, warn: false, exceeded: false };
61
+ const ratio = spend / budget;
62
+ // warn from 80%, exceeded at 100%
63
+ return { spend, budget, ratio, warn: ratio >= 0.8, exceeded: ratio >= 1 };
64
+ }
package/lib/pool.js CHANGED
@@ -1,227 +1,237 @@
1
- // Pure helpers for the key-rotation plugin. Kept free of DSH runtime
2
- // dependencies so they can be unit-tested under `node --test` without
3
- // `@deepseek-ai/cordis` / `@deepseek-ai/schemastery` being installed.
4
- // Anything that talks to `ctx` stays in lib/index.js.
5
-
6
- /** Number of trailing characters of a key shown in the UI for disambiguation. */
7
- export const KEY_TAIL_CHARS = 5;
8
-
9
- /** Returns the last KEY_TAIL_CHARS characters of a key, or the whole key if shorter. */
10
- export function keyTail(value) {
11
- if (typeof value !== 'string' || value.length === 0) return '';
12
- return value.length <= KEY_TAIL_CHARS ? value : value.slice(-KEY_TAIL_CHARS);
13
- }
14
-
15
- /** True if a socket remoteAddress is a loopback (v4 / v6). */
16
- export function isLoopbackAddress(address) {
17
- if (address === undefined || address === null) return false;
18
- if (address === '127.0.0.1' || address === '::1') return true;
19
- if (typeof address === 'string' && address.startsWith('::ffff:')) {
20
- return address.slice(7) === '127.0.0.1';
21
- }
22
- return false;
23
- }
24
-
25
- /** True if a request is both loopback and same-origin (sec-fetch-site guard). */
26
- export function isTrustedBridgeRequest(request) {
27
- if (!isLoopbackAddress(request?.socket?.remoteAddress)) return false;
28
- if (request?.headers?.['sec-fetch-site'] === 'cross-site') return false;
29
- const origin = request?.headers?.origin;
30
- if (origin === undefined) return true; // no Origin header (loopback tool) is allowed
31
- try {
32
- const host = request?.headers?.host;
33
- if (host === undefined) return false;
34
- return new URL(origin).host === host;
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
- /** Compiled once at module load. Matches error messages that should rotate the key
41
- * even when the structured failure code is not in `switchCodes`.
42
- * Mirrors SWITCHABLE_MESSAGE_PATTERN in lib/index.js exactly. */
43
- export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
44
- /\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
45
- /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
46
- /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
47
- /\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
48
- /\bbilling\b/i,
49
- /\b429\b|\b5\d\d\b/i,
50
- /\btime(?:d)?\s*out\b|timeout/i,
51
- /\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
52
- /\bother side closed|premature close|stream ended (?:before|without)\b/i,
53
- /\b401\b|\b403\b/i,
54
- /\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
55
- /\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
56
- /\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
57
- ].map((r) => r.source).join('|'), 'i');
58
-
59
- /** Default switch codes used by lib/index.js. Kept here so tests assert against
60
- * the same list the runtime ships. */
61
- export const DEFAULT_SWITCH_CODES = [
62
- 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
63
- 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH',
64
- ];
65
-
66
- /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
67
- const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
68
- export function isValidRef(ref) {
69
- return typeof ref === 'string' && REF_RE.test(ref);
70
- }
71
-
72
- /**
73
- * Pick the next healthy ref in a round-robin pool.
74
- *
75
- * @param {{ refs: string[], state: { failedUntil: Map<string, number>, pointer: number } }} pool
76
- * @param {number} now - epoch milliseconds (injectable for tests)
77
- * @param {number} refsCount - for tests: number of refs to consider (defaults to pool.refs.length)
78
- * @returns {string|undefined} the ref to use, or undefined if pool has no refs
79
- *
80
- * Mirrors the inline logic in the credentials.resolve patch in lib/index.js.
81
- * Exported here for unit tests; not used at runtime to avoid duplicating logic.
82
- */
83
- export function pickNext(pool, now, refsCount = pool.refs.length) {
84
- if (refsCount === 0) return undefined;
85
- const start = pool.state.pointer ?? 0;
86
- for (let i = 0; i < refsCount; i++) {
87
- const index = (start + i) % refsCount;
88
- const candidate = pool.refs[index];
89
- const until = pool.state.failedUntil.get(candidate);
90
- if (until !== undefined && until > now) continue;
91
- return candidate;
92
- }
93
- return undefined; // all cooled
94
- }
95
-
96
- /** Apply a failed-key cooldown to pool state. Pure: returns next state shape,
97
- * does not mutate input. Runtime in lib/index.js mutates in place; tests use
98
- * this helper to construct expected snapshots. */
99
- export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
100
- return {
101
- ...pool,
102
- state: {
103
- ...pool.state,
104
- failedUntil: new Map(pool.state.failedUntil).set(ref, now + cooldownMs),
105
- },
106
- };
107
- }
108
-
109
- /** Exponential backoff for a repeatedly failing key.
110
- * failCount 1 => baseMs, 2 => baseMs*2, 3 => baseMs*4, capped at baseMs*8 (or maxMs).
111
- * Pure and easily unit-tested. */
112
- export function computeBackoff(baseMs, failCount, maxMs) {
113
- const cap = maxMs ?? baseMs * 8;
114
- if (failCount <= 1) return Math.min(baseMs, cap);
115
- const backoff = baseMs * (1 << (failCount - 1)); // 2^(n-1)
116
- return Math.min(backoff, cap);
117
- }
118
-
119
- /** Record a failure for `ref` in `pool.state`, applying exponential backoff.
120
- * Mutates pool.state.failedUntil and pool.state.failCounts. Returns the backoff used. */
121
- export function recordFailure(pool, ref, now, baseMs, maxMs) {
122
- if (!pool.state.failCounts) pool.state.failCounts = new Map();
123
- const prev = pool.state.failCounts.get(ref) ?? 0;
124
- const next = prev + 1;
125
- pool.state.failCounts.set(ref, next);
126
- const backoff = computeBackoff(baseMs, next, maxMs);
127
- pool.state.failedUntil.set(ref, now + backoff);
128
- return backoff;
129
- }
130
-
131
- /** Record a success for `ref` — clears its cooldown and resets its fail count. */
132
- export function recordSuccess(pool, ref) {
133
- if (pool.state.failCounts) pool.state.failCounts.delete(ref);
134
- pool.state.failedUntil.delete(ref);
135
- }
136
-
137
- /** Return env value for ref if present in process.env, else undefined. */
138
- export function envValue(ref) {
139
- const v = typeof process !== 'undefined' ? process.env?.[ref] : undefined;
140
- return typeof v === 'string' && v.length > 0 ? v : undefined;
141
- }
142
-
143
- /** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
144
- export function sweepExpired(poolState, now = Date.now()) {
145
- let cleared = 0;
146
- for (const st of poolState.values()) {
147
- for (const [ref, until] of [...st.failedUntil.entries()]) {
148
- if (until <= now) {
149
- st.failedUntil.delete(ref);
150
- st.failCounts?.delete(ref);
151
- cleared++;
152
- }
153
- }
154
- }
155
- return cleared;
156
- }
157
-
158
- /** Parse Retry-After value from a header string or message.
159
- * Supports seconds ("60", "Retry-After: 60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
160
- * Returns milliseconds or undefined if not parseable. */
161
- export function parseRetryAfter(value) {
162
- if (typeof value !== 'string' || !value) return undefined;
163
- // Try to extract "Retry-After: <val>" from a larger message
164
- const m = value.match(/retry-after\s*[:=]\s*(.+)/i);
165
- const raw = m ? m[1].trim().split(/[\n\r;]/)[0].trim() : value.trim();
166
- // Seconds
167
- if (/^\d+$/.test(raw)) {
168
- const sec = Number(raw);
169
- if (sec >= 0 && sec <= 86400 * 7) return sec * 1000;
170
- }
171
- // HTTP-date
172
- const ts = Date.parse(raw);
173
- if (!Number.isNaN(ts)) {
174
- const diff = ts - Date.now();
175
- if (diff > 0 && diff < 86400 * 7 * 1000) return diff;
176
- }
177
- return undefined;
178
- }
179
-
180
- /** Pick a key pool for a (provider, model) pair. Model sub-pools win over the
181
- * provider base pool; falls back to the base pool when no sub-pool matches. */
182
- export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
183
- const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
184
- return (byModel && byModel.get(model)) || (providerToPool && providerToPool.get(provider)) || null;
185
- }
186
-
187
- /** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
188
- export function parseExpiry(v) {
189
- if (typeof v === 'number' && v > 0) return v;
190
- if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
191
- return undefined;
192
- }
193
-
194
- /** Compute a 0..100 health score for a pool based on its runtime state.
195
- * Deductions: switches * 5, exhaustions * 10, broken keys * 15. */
196
- export function computeHealthScore(state) {
197
- if (!state || typeof state !== 'object') return 100;
198
- const switches = state.switches ?? 0;
199
- const exhaustions = state.exhaustionCount ?? 0;
200
- const broken = state.brokenUntil ? state.brokenUntil.size : 0;
201
- return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
202
- }
203
-
204
- /** Extract rate-limit info from an object that may carry response headers.
205
- * Looks for X-RateLimit-Remaining / X-RateLimit-Limit / X-RateLimit-Reset
206
- * (case-insensitive) in headers. Returns { remaining, limit, reset } or null. */
207
- export function extractRateLimit(headers) {
208
- if (!headers || typeof headers !== 'object') return null;
209
- const get = (name) => {
210
- const v = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
211
- if (v === undefined || v === null) return undefined;
212
- return Number(String(v));
213
- };
214
- const remaining = get('X-RateLimit-Remaining');
215
- const limit = get('X-RateLimit-Limit');
216
- const reset = get('X-RateLimit-Reset');
217
- if (remaining === undefined && limit === undefined) return null;
218
- return { remaining, limit, reset };
219
- }
220
-
221
- /** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
222
- export function isRateLimited(rate, threshold = 0.1) {
223
- if (!rate) return false;
224
- if (rate.remaining === undefined) return false;
225
- if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
226
- return rate.remaining <= 0;
227
- }
1
+ // Pure helpers for the key-rotation plugin. Kept free of DSH runtime
2
+ // dependencies so they can be unit-tested under `node --test` without
3
+ // `@deepseek-ai/cordis` / `@deepseek-ai/schemastery` being installed.
4
+ // Anything that talks to `ctx` stays in lib/index.js.
5
+
6
+ /** Number of trailing characters of a key shown in the UI for disambiguation. */
7
+ export const KEY_TAIL_CHARS = 5;
8
+
9
+ /** Returns the last KEY_TAIL_CHARS characters of a key, or the whole key if shorter. */
10
+ export function keyTail(value) {
11
+ if (typeof value !== 'string' || value.length === 0) return '';
12
+ return value.length <= KEY_TAIL_CHARS ? value : value.slice(-KEY_TAIL_CHARS);
13
+ }
14
+
15
+ /** True if a socket remoteAddress is a loopback (v4 / v6). */
16
+ export function isLoopbackAddress(address) {
17
+ if (address === undefined || address === null) return false;
18
+ if (address === '127.0.0.1' || address === '::1') return true;
19
+ if (typeof address === 'string' && address.startsWith('::ffff:')) {
20
+ return address.slice(7) === '127.0.0.1';
21
+ }
22
+ return false;
23
+ }
24
+
25
+ /** True if a request is both loopback and same-origin (sec-fetch-site guard). */
26
+ export function isTrustedBridgeRequest(request) {
27
+ if (!isLoopbackAddress(request?.socket?.remoteAddress)) return false;
28
+ if (request?.headers?.['sec-fetch-site'] === 'cross-site') return false;
29
+ const origin = request?.headers?.origin;
30
+ if (origin === undefined) return true; // no Origin header (loopback tool) is allowed
31
+ try {
32
+ const host = request?.headers?.host;
33
+ if (host === undefined) return false;
34
+ return new URL(origin).host === host;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** Compiled once at module load. Matches error messages that should rotate the key
41
+ * even when the structured failure code is not in `switchCodes`.
42
+ * Mirrors SWITCHABLE_MESSAGE_PATTERN in lib/index.js exactly. */
43
+ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
44
+ /\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
45
+ /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
46
+ /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
47
+ /\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
48
+ /\bbilling\b/i,
49
+ /\b429\b|\b5\d\d\b/i,
50
+ /\btime(?:d)?\s*out\b|timeout/i,
51
+ /\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
52
+ /\bother side closed|premature close|stream ended (?:before|without)\b/i,
53
+ /\b401\b|\b403\b/i,
54
+ /\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
55
+ /\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
56
+ /\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
57
+ ].map((r) => r.source).join('|'), 'i');
58
+
59
+ /** Default switch codes used by lib/index.js. Kept here so tests assert against
60
+ * the same list the runtime ships. */
61
+ export const DEFAULT_SWITCH_CODES = [
62
+ 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
63
+ 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH',
64
+ ];
65
+
66
+ /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
67
+ const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
68
+ export function isValidRef(ref) {
69
+ return typeof ref === 'string' && REF_RE.test(ref);
70
+ }
71
+
72
+ /**
73
+ * Pick the next healthy ref in a round-robin pool.
74
+ *
75
+ * @param {{ refs: string[], state: { failedUntil: Map<string, number>, pointer: number } }} pool
76
+ * @param {number} now - epoch milliseconds (injectable for tests)
77
+ * @param {number} refsCount - for tests: number of refs to consider (defaults to pool.refs.length)
78
+ * @returns {string|undefined} the ref to use, or undefined if pool has no refs
79
+ *
80
+ * Mirrors the inline logic in the credentials.resolve patch in lib/index.js.
81
+ * Exported here for unit tests; not used at runtime to avoid duplicating logic.
82
+ */
83
+ export function pickNext(pool, now, refsCount = pool.refs.length) {
84
+ if (refsCount === 0) return undefined;
85
+ const start = pool.state.pointer ?? 0;
86
+ for (let i = 0; i < refsCount; i++) {
87
+ const index = (start + i) % refsCount;
88
+ const candidate = pool.refs[index];
89
+ const until = pool.state.failedUntil.get(candidate);
90
+ if (until !== undefined && until > now) continue;
91
+ return candidate;
92
+ }
93
+ return undefined; // all cooled
94
+ }
95
+
96
+ /** Apply a failed-key cooldown to pool state. Pure: returns next state shape,
97
+ * does not mutate input. Runtime in lib/index.js mutates in place; tests use
98
+ * this helper to construct expected snapshots. */
99
+ export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
100
+ return {
101
+ ...pool,
102
+ state: {
103
+ ...pool.state,
104
+ failedUntil: new Map(pool.state.failedUntil).set(ref, now + cooldownMs),
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Exponential backoff for a repeatedly failing key.
110
+ * failCount 1 => baseMs, 2 => baseMs*2, 3 => baseMs*4, capped at baseMs*8 (or maxMs).
111
+ * Pure and easily unit-tested. */
112
+ export function computeBackoff(baseMs, failCount, maxMs) {
113
+ const cap = maxMs ?? baseMs * 8;
114
+ if (failCount <= 1) return Math.min(baseMs, cap);
115
+ const backoff = baseMs * (1 << (failCount - 1)); // 2^(n-1)
116
+ return Math.min(backoff, cap);
117
+ }
118
+
119
+ /** Record a failure for `ref` in `pool.state`, applying exponential backoff.
120
+ * Mutates pool.state.failedUntil and pool.state.failCounts. Returns the backoff used. */
121
+ export function recordFailure(pool, ref, now, baseMs, maxMs) {
122
+ if (!pool.state.failCounts) pool.state.failCounts = new Map();
123
+ const prev = pool.state.failCounts.get(ref) ?? 0;
124
+ const next = prev + 1;
125
+ pool.state.failCounts.set(ref, next);
126
+ const backoff = computeBackoff(baseMs, next, maxMs);
127
+ pool.state.failedUntil.set(ref, now + backoff);
128
+ return backoff;
129
+ }
130
+
131
+ /** Record a success for `ref` — clears its cooldown and resets its fail count. */
132
+ export function recordSuccess(pool, ref) {
133
+ if (pool.state.failCounts) pool.state.failCounts.delete(ref);
134
+ pool.state.failedUntil.delete(ref);
135
+ }
136
+
137
+ /** Return env value for ref if present in process.env, else undefined. */
138
+ export function envValue(ref) {
139
+ const v = typeof process !== 'undefined' ? process.env?.[ref] : undefined;
140
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
141
+ }
142
+
143
+ /** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
144
+ export function sweepExpired(poolState, now = Date.now()) {
145
+ let cleared = 0;
146
+ for (const st of poolState.values()) {
147
+ for (const [ref, until] of [...st.failedUntil.entries()]) {
148
+ if (until <= now) {
149
+ st.failedUntil.delete(ref);
150
+ st.failCounts?.delete(ref);
151
+ cleared++;
152
+ }
153
+ }
154
+ }
155
+ return cleared;
156
+ }
157
+
158
+ /** Parse Retry-After value from a header string or message.
159
+ * Supports seconds ("60", "Retry-After: 60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
160
+ * Returns milliseconds or undefined if not parseable. */
161
+ export function parseRetryAfter(value) {
162
+ if (typeof value !== 'string' || !value) return undefined;
163
+ // Try to extract "Retry-After: <val>" from a larger message
164
+ const m = value.match(/retry-after\s*[:=]\s*(.+)/i);
165
+ const raw = m ? m[1].trim().split(/[\n\r;]/)[0].trim() : value.trim();
166
+ // Seconds
167
+ if (/^\d+$/.test(raw)) {
168
+ const sec = Number(raw);
169
+ if (sec >= 0 && sec <= 86400 * 7) return sec * 1000;
170
+ }
171
+ // HTTP-date
172
+ const ts = Date.parse(raw);
173
+ if (!Number.isNaN(ts)) {
174
+ const diff = ts - Date.now();
175
+ if (diff > 0 && diff < 86400 * 7 * 1000) return diff;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ /** Pick a key pool for a (provider, model) pair. Model sub-pools win over the
181
+ * provider base pool; falls back to the base pool when no sub-pool matches.
182
+ * #195 tier-aware: an exact model pool wins; otherwise the longest model-key
183
+ * that is a family prefix of the requested model wins (e.g. a pool defined
184
+ * for "gpt-4o" serves "gpt-4o-mini-2024"); otherwise the base pool. */
185
+ export function selectPool(modelPoolByProvider, providerToPool, provider, model) {
186
+ const byModel = modelPoolByProvider && modelPoolByProvider.get(provider);
187
+ const base = providerToPool && providerToPool.get(provider);
188
+ if (!byModel || !model) return base ?? null;
189
+ if (byModel.has(model)) return byModel.get(model);
190
+ let best = null;
191
+ for (const key of byModel.keys()) {
192
+ if (model.startsWith(key) && key.length > (best ? best.length : 0)) best = key;
193
+ }
194
+ return (best ? byModel.get(best) : base) ?? null;
195
+ }
196
+
197
+ /** Parse an expiry value (timestamp ms or ISO date string) to epoch ms. */
198
+ export function parseExpiry(v) {
199
+ if (typeof v === 'number' && v > 0) return v;
200
+ if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
201
+ return undefined;
202
+ }
203
+
204
+ /** Compute a 0..100 health score for a pool based on its runtime state.
205
+ * Deductions: switches * 5, exhaustions * 10, broken keys * 15. */
206
+ export function computeHealthScore(state) {
207
+ if (!state || typeof state !== 'object') return 100;
208
+ const switches = state.switches ?? 0;
209
+ const exhaustions = state.exhaustionCount ?? 0;
210
+ const broken = state.brokenUntil ? state.brokenUntil.size : 0;
211
+ return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
212
+ }
213
+
214
+ /** Extract rate-limit info from an object that may carry response headers.
215
+ * Looks for X-RateLimit-Remaining / X-RateLimit-Limit / X-RateLimit-Reset
216
+ * (case-insensitive) in headers. Returns { remaining, limit, reset } or null. */
217
+ export function extractRateLimit(headers) {
218
+ if (!headers || typeof headers !== 'object') return null;
219
+ const get = (name) => {
220
+ const v = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
221
+ if (v === undefined || v === null) return undefined;
222
+ return Number(String(v));
223
+ };
224
+ const remaining = get('X-RateLimit-Remaining');
225
+ const limit = get('X-RateLimit-Limit');
226
+ const reset = get('X-RateLimit-Reset');
227
+ if (remaining === undefined && limit === undefined) return null;
228
+ return { remaining, limit, reset };
229
+ }
230
+
231
+ /** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
232
+ export function isRateLimited(rate, threshold = 0.1) {
233
+ if (!rate) return false;
234
+ if (rate.remaining === undefined) return false;
235
+ if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
236
+ return rate.remaining <= 0;
237
+ }
@@ -1,45 +1,45 @@
1
- // quota-window.js — calendar-based quota reset windows (issue #197).
2
-
3
- export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'];
4
-
5
- export function nextQuotaReset(quotaResetWindow, now) {
6
- now = now || Date.now();
7
- if (!quotaResetWindow || typeof quotaResetWindow !== 'object') return null;
8
- const type = quotaResetWindow.type;
9
- const hour = Number.isFinite(quotaResetWindow.hour) ? quotaResetWindow.hour : 0;
10
-
11
- if (type === 'rolling_24h') {
12
- const d = new Date(now);
13
- d.setUTCHours(d.getUTCHours() + 24, 0, 0, 0);
14
- return d.getTime();
15
- }
16
-
17
- if (type === 'midnight_utc') {
18
- const d = new Date(now);
19
- d.setUTCHours(hour, 0, 0, 0);
20
- if (d.getTime() <= now) d.setUTCDate(d.getUTCDate() + 1);
21
- return d.getTime();
22
- }
23
-
24
- if (type === 'midnight_pst') {
25
- const PST_OFFSET_MS = 8 * 3600_000;
26
- const shifted = now + PST_OFFSET_MS;
27
- const d = new Date(shifted);
28
- d.setUTCHours(hour, 0, 0, 0);
29
- if (d.getTime() <= shifted) d.setUTCDate(d.getUTCDate() + 1);
30
- return d.getTime() - PST_OFFSET_MS;
31
- }
32
-
33
- return null;
34
- }
35
-
36
- export function poolResetAt(pool, quotaResetWindow, now) {
37
- return nextQuotaReset(quotaResetWindow, now);
38
- }
39
-
40
- export function isBlockedUntilReset(failedUntil, resetAt, now) {
41
- now = now || Date.now();
42
- if (!Number.isFinite(failedUntil)) return false;
43
- if (resetAt === null || resetAt === undefined) return false;
44
- return failedUntil >= resetAt;
45
- }
1
+ // quota-window.js — calendar-based quota reset windows (issue #197).
2
+
3
+ export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'];
4
+
5
+ export function nextQuotaReset(quotaResetWindow, now) {
6
+ now = now || Date.now();
7
+ if (!quotaResetWindow || typeof quotaResetWindow !== 'object') return null;
8
+ const type = quotaResetWindow.type;
9
+ const hour = Number.isFinite(quotaResetWindow.hour) ? quotaResetWindow.hour : 0;
10
+
11
+ if (type === 'rolling_24h') {
12
+ const d = new Date(now);
13
+ d.setUTCHours(d.getUTCHours() + 24, 0, 0, 0);
14
+ return d.getTime();
15
+ }
16
+
17
+ if (type === 'midnight_utc') {
18
+ const d = new Date(now);
19
+ d.setUTCHours(hour, 0, 0, 0);
20
+ if (d.getTime() <= now) d.setUTCDate(d.getUTCDate() + 1);
21
+ return d.getTime();
22
+ }
23
+
24
+ if (type === 'midnight_pst') {
25
+ const PST_OFFSET_MS = 8 * 3600_000;
26
+ const shifted = now + PST_OFFSET_MS;
27
+ const d = new Date(shifted);
28
+ d.setUTCHours(hour, 0, 0, 0);
29
+ if (d.getTime() <= shifted) d.setUTCDate(d.getUTCDate() + 1);
30
+ return d.getTime() - PST_OFFSET_MS;
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ export function poolResetAt(pool, quotaResetWindow, now) {
37
+ return nextQuotaReset(quotaResetWindow, now);
38
+ }
39
+
40
+ export function isBlockedUntilReset(failedUntil, resetAt, now) {
41
+ now = now || Date.now();
42
+ if (!Number.isFinite(failedUntil)) return false;
43
+ if (resetAt === null || resetAt === undefined) return false;
44
+ return failedUntil >= resetAt;
45
+ }