@goodandready/dsh-key-rotation 0.8.8 → 0.8.11
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/README.md +40 -3
- package/README.ru.md +40 -3
- package/README.zh.md +29 -4
- package/lib/best-effort.js +26 -0
- package/lib/bucket.js +1 -87
- package/lib/cascade.js +27 -39
- package/lib/client.js +360 -55
- package/lib/clock.js +0 -2
- package/lib/concurrency.js +9 -0
- package/lib/error-taxonomy.js +0 -3
- package/lib/heal.js +47 -0
- package/lib/http-bridge.js +0 -7
- package/lib/index.js +65 -10
- package/lib/plugin-updater.js +277 -0
- package/lib/pool.js +109 -29
- package/lib/rotate.js +127 -107
- package/lib/routes-ops.js +5 -2
- package/lib/sandbox.js +9 -1
- package/lib/webhook.js +2 -1
- package/package.json +5 -2
package/lib/pool.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bestEffort } from './best-effort.js';
|
|
1
2
|
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
2
3
|
// Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
|
|
3
4
|
|
|
@@ -71,11 +72,6 @@ export const SOFT_FAILURE_CODES = new Set([
|
|
|
71
72
|
]);
|
|
72
73
|
|
|
73
74
|
/** True if failure code or message represents a soft/transient drop. */
|
|
74
|
-
export function isSoftFailure(code, message = '') {
|
|
75
|
-
if (code && SOFT_FAILURE_CODES.has(String(code).toUpperCase())) return true;
|
|
76
|
-
if (/502|503|504|timeout|econnreset|econnrefused|socket hang up/i.test(message)) return true;
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
75
|
|
|
80
76
|
/** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
|
|
81
77
|
const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -184,9 +180,10 @@ export function envValue(ref) {
|
|
|
184
180
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
185
181
|
}
|
|
186
182
|
|
|
187
|
-
/** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
|
|
188
|
-
export function sweepExpired(poolState, now = Date.now()) {
|
|
183
|
+
/** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
|
|
184
|
+
export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
189
185
|
let cleared = 0;
|
|
186
|
+
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
190
187
|
for (const st of poolState.values()) {
|
|
191
188
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
192
189
|
if (until <= now) {
|
|
@@ -195,6 +192,16 @@ export function sweepExpired(poolState, now = Date.now()) {
|
|
|
195
192
|
cleared++;
|
|
196
193
|
}
|
|
197
194
|
}
|
|
195
|
+
// Prune stale entries for keys no longer in active configuration
|
|
196
|
+
if (activeSet) {
|
|
197
|
+
for (const map of [st.failedUntil, st.failCounts, st.authFailCounts, st.brokenUntil, st.costPerKey, st.lastUsedAt, st.usageCounts, st.byModel, st.usageDays, st.quotaWindows, st.rpmWindows, st.probedAt]) {
|
|
198
|
+
if (map && typeof map.keys === 'function') {
|
|
199
|
+
for (const k of [...map.keys()]) {
|
|
200
|
+
if (!activeSet.has(k)) map.delete(k);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
198
205
|
}
|
|
199
206
|
return cleared;
|
|
200
207
|
}
|
|
@@ -248,33 +255,105 @@ export function computeHealthScore(state) {
|
|
|
248
255
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
249
256
|
export function extractRateLimit(headers) {
|
|
250
257
|
if (!headers || typeof headers !== 'object') return null;
|
|
251
|
-
let remaining, limit, reset;
|
|
258
|
+
let remaining, limit, reset, retryAfter;
|
|
252
259
|
for (const k in headers) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
const lower = k.toLowerCase();
|
|
261
|
+
const v = headers[k];
|
|
262
|
+
if (v == null) continue;
|
|
263
|
+
if (lower === 'retry-after') {
|
|
264
|
+
const num = Number(v);
|
|
265
|
+
if (Number.isFinite(num) && num >= 0) {
|
|
266
|
+
retryAfter = Math.ceil(num);
|
|
267
|
+
} else {
|
|
268
|
+
const parsed = Date.parse(v);
|
|
269
|
+
if (!Number.isNaN(parsed)) {
|
|
270
|
+
retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else if (
|
|
274
|
+
lower === 'x-ratelimit-remaining' ||
|
|
275
|
+
lower === 'x-ratelimit-remaining-requests' ||
|
|
276
|
+
lower === 'anthropic-ratelimit-requests-remaining' ||
|
|
277
|
+
lower === 'openai-ratelimit-remaining-requests' ||
|
|
278
|
+
lower === 'ratelimit-remaining'
|
|
279
|
+
) {
|
|
280
|
+
const n = Number(String(v));
|
|
281
|
+
if (Number.isFinite(n)) remaining = n;
|
|
282
|
+
} else if (
|
|
283
|
+
(lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
|
|
284
|
+
remaining === undefined
|
|
285
|
+
) {
|
|
286
|
+
const n = Number(String(v));
|
|
287
|
+
if (Number.isFinite(n)) remaining = n;
|
|
288
|
+
} else if (
|
|
289
|
+
lower === 'x-ratelimit-limit' ||
|
|
290
|
+
lower === 'x-ratelimit-limit-requests' ||
|
|
291
|
+
lower === 'anthropic-ratelimit-requests-limit' ||
|
|
292
|
+
lower === 'openai-ratelimit-limit-requests' ||
|
|
293
|
+
lower === 'ratelimit-limit'
|
|
294
|
+
) {
|
|
295
|
+
const n = Number(String(v));
|
|
296
|
+
if (Number.isFinite(n)) limit = n;
|
|
297
|
+
} else if (
|
|
298
|
+
lower === 'x-ratelimit-reset' ||
|
|
299
|
+
lower === 'x-ratelimit-reset-requests' ||
|
|
300
|
+
lower === 'anthropic-ratelimit-requests-reset' ||
|
|
301
|
+
lower === 'ratelimit-reset'
|
|
302
|
+
) {
|
|
303
|
+
const n = Number(String(v));
|
|
304
|
+
if (Number.isFinite(n)) {
|
|
305
|
+
reset = n;
|
|
306
|
+
} else {
|
|
307
|
+
const parsed = Date.parse(v);
|
|
308
|
+
if (!Number.isNaN(parsed)) reset = parsed;
|
|
265
309
|
}
|
|
266
310
|
}
|
|
267
311
|
}
|
|
268
|
-
if (remaining === undefined && limit === undefined) return null;
|
|
269
|
-
|
|
312
|
+
if (remaining === undefined && limit === undefined && retryAfter === undefined && reset === undefined) return null;
|
|
313
|
+
const out = { remaining, limit, reset };
|
|
314
|
+
if (retryAfter !== undefined) out.retryAfter = retryAfter;
|
|
315
|
+
return out;
|
|
270
316
|
}
|
|
271
317
|
|
|
272
|
-
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
|
|
318
|
+
/** True if remaining is below the given threshold fraction of limit (e.g. 0.1) or retry-after is active. */
|
|
273
319
|
export function isRateLimited(rate, threshold = 0.1) {
|
|
274
320
|
if (!rate) return false;
|
|
275
|
-
if (rate.
|
|
276
|
-
if (rate.
|
|
277
|
-
|
|
321
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) return true;
|
|
322
|
+
if (rate.remaining !== undefined && rate.remaining <= 1) return true;
|
|
323
|
+
if (rate.limit && rate.limit > 0 && rate.remaining !== undefined) {
|
|
324
|
+
return rate.remaining < rate.limit * threshold;
|
|
325
|
+
}
|
|
326
|
+
if (rate.remaining !== undefined) return rate.remaining <= 0;
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Sort or prioritize an attempt list of refs according to routing strategy.
|
|
332
|
+
* Strategies:
|
|
333
|
+
* - 'round-robin': preserves order
|
|
334
|
+
* - 'least-loaded': keys with lowest active concurrency count first
|
|
335
|
+
* - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
|
|
336
|
+
*/
|
|
337
|
+
export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
|
|
338
|
+
if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
|
|
339
|
+
const list = refs.slice();
|
|
340
|
+
if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
|
|
341
|
+
return list.sort((a, b) => {
|
|
342
|
+
const ca = deps.concurrencyTracker.getActive(a) ?? 0;
|
|
343
|
+
const cb = deps.concurrencyTracker.getActive(b) ?? 0;
|
|
344
|
+
return ca - cb;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
|
|
348
|
+
return list.sort((a, b) => {
|
|
349
|
+
const sa = deps.latencyHistogram.snapshot(a);
|
|
350
|
+
const sb = deps.latencyHistogram.snapshot(b);
|
|
351
|
+
const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
|
|
352
|
+
const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
|
|
353
|
+
return la - lb;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return list;
|
|
278
357
|
}
|
|
279
358
|
|
|
280
359
|
/**
|
|
@@ -386,7 +465,7 @@ export function budgetVerdict(spend, budget) {
|
|
|
386
465
|
/** Safely reset a provider's circuit breaker to closed state. */
|
|
387
466
|
export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
388
467
|
if (!circuitBreaker || !provider) return false;
|
|
389
|
-
|
|
468
|
+
const ok = bestEffort('resetCircuitForProvider', () => {
|
|
390
469
|
if (typeof circuitBreaker.reset === 'function') {
|
|
391
470
|
circuitBreaker.reset(provider);
|
|
392
471
|
return true;
|
|
@@ -395,6 +474,7 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
|
395
474
|
circuitBreaker.onSuccess(provider);
|
|
396
475
|
return true;
|
|
397
476
|
}
|
|
398
|
-
|
|
399
|
-
|
|
477
|
+
return false;
|
|
478
|
+
});
|
|
479
|
+
return ok === true;
|
|
400
480
|
}
|
package/lib/rotate.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
extractRateLimit,
|
|
10
10
|
isRateLimited,
|
|
11
11
|
formatExhaustionMessage,
|
|
12
|
+
sortAttemptList,
|
|
12
13
|
} from './pool.js';
|
|
13
14
|
import { pickCascadeFallback } from './cascade.js';
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ export function createRotate(deps) {
|
|
|
25
26
|
notifySwitch,
|
|
26
27
|
notifyExhaustion,
|
|
27
28
|
recordLatency,
|
|
29
|
+
latencyHistogram,
|
|
28
30
|
concurrencyTracker,
|
|
29
31
|
MARKER,
|
|
30
32
|
finishError,
|
|
@@ -42,7 +44,11 @@ export function createRotate(deps) {
|
|
|
42
44
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
43
45
|
setRotateStartMs(reqStore.startMs);
|
|
44
46
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
45
|
-
|
|
47
|
+
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
48
|
+
if (strategy !== 'round-robin') {
|
|
49
|
+
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
|
|
50
|
+
}
|
|
51
|
+
if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
46
52
|
// #193: prefer least-loaded key within limit
|
|
47
53
|
const available = attemptList.filter((r) => {
|
|
48
54
|
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
@@ -115,122 +121,136 @@ export function createRotate(deps) {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
118
|
-
|
|
124
|
+
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
119
125
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (chunk && chunk.type === 'finish') {
|
|
129
|
-
const kind = chunk.reason?.kind;
|
|
130
|
-
const failure = chunk.reason?.failure;
|
|
131
|
-
const code = failure?.code;
|
|
132
|
-
const message = failure?.message ?? '';
|
|
133
|
-
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
134
|
-
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
135
|
-
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
136
|
-
if (switchable) {
|
|
137
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
138
|
-
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
139
|
-
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
140
|
-
pool.state.lastSwitchAt = now();
|
|
141
|
-
lastFailure = chunk;
|
|
142
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
143
|
-
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
144
|
-
if (switchNotify && activeRef) {
|
|
145
|
-
notifySwitch(runtime0, pool, {
|
|
146
|
-
provider: options.provider,
|
|
147
|
-
from: activeRef,
|
|
148
|
-
code: String(code ?? 'UNKNOWN'),
|
|
149
|
-
at: pool.state.lastSwitchAt,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
switching = true;
|
|
153
|
-
break;
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of inner) {
|
|
128
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
129
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
130
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
131
|
+
yielded = true;
|
|
132
|
+
yield chunk;
|
|
133
|
+
continue;
|
|
154
134
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
pool.state.
|
|
135
|
+
if (chunk && chunk.type === 'finish') {
|
|
136
|
+
const kind = chunk.reason?.kind;
|
|
137
|
+
const failure = chunk.reason?.failure;
|
|
138
|
+
const code = failure?.code;
|
|
139
|
+
const message = failure?.message ?? '';
|
|
140
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
141
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
142
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
143
|
+
if (switchable) {
|
|
144
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
145
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
146
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
147
|
+
pool.state.lastSwitchAt = now();
|
|
148
|
+
lastFailure = chunk;
|
|
149
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
150
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
151
|
+
if (switchNotify && activeRef) {
|
|
152
|
+
notifySwitch(runtime0, pool, {
|
|
153
|
+
provider: options.provider,
|
|
154
|
+
from: activeRef,
|
|
155
|
+
code: String(code ?? 'UNKNOWN'),
|
|
156
|
+
at: pool.state.lastSwitchAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
switching = true;
|
|
160
|
+
break;
|
|
167
161
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
182
|
-
}
|
|
183
|
-
// Proactive rate-limit (#115): if response headers say this key is near
|
|
184
|
-
// its quota, cool it down so the NEXT request starts on a different key.
|
|
185
|
-
// We do NOT re-run this (already successful) request — that would double-send.
|
|
186
|
-
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
187
|
-
if (rate && activeRef) {
|
|
188
|
-
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
189
|
-
const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
|
|
190
|
-
recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
|
|
191
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
192
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
162
|
+
// cost tracking if provider returns usage.cost
|
|
163
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
164
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
165
|
+
const c = Number(chunk.usage.cost);
|
|
166
|
+
if (!isNaN(c)) {
|
|
167
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
168
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
169
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
170
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
171
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
172
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
173
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
174
|
+
}
|
|
193
175
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
176
|
+
// Usage by day (#119)
|
|
177
|
+
if (activeRef) {
|
|
178
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
179
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
180
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
181
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
182
|
+
}
|
|
183
|
+
// Per-model request detail (#121)
|
|
184
|
+
if (activeRef && options.model) {
|
|
185
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
186
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
187
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
188
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
191
|
+
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
192
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
193
|
+
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
194
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
195
|
+
if (guardEnabled && rate && activeRef) {
|
|
196
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
197
|
+
let cool;
|
|
198
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
199
|
+
cool = rate.retryAfter * 1000;
|
|
200
|
+
} else if (rate.reset && rate.reset > now()) {
|
|
201
|
+
cool = rate.reset - now();
|
|
202
|
+
} else {
|
|
203
|
+
cool = pool.cooldownMs ?? cooldownMs;
|
|
204
|
+
}
|
|
205
|
+
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
206
|
+
const effCool = Math.min(cool, maxCool ?? cool);
|
|
207
|
+
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
208
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
209
|
+
console.warn(`[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`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
213
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
214
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
215
|
+
}
|
|
216
|
+
yield chunk;
|
|
217
|
+
recordLatency(pool, reqStore);
|
|
218
|
+
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
219
|
+
return;
|
|
198
220
|
}
|
|
199
221
|
yield chunk;
|
|
200
|
-
recordLatency(pool, reqStore);
|
|
201
|
-
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
202
|
-
return;
|
|
203
222
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
225
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
226
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
227
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
229
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
230
|
+
pool.state.lastSwitchAt = now();
|
|
231
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
232
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
233
|
+
if (switchNotify && activeRef) {
|
|
234
|
+
notifySwitch(runtime0, pool, {
|
|
235
|
+
provider: options.provider,
|
|
236
|
+
from: activeRef,
|
|
237
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
238
|
+
at: pool.state.lastSwitchAt,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
continue; // Failover to next key!
|
|
224
242
|
}
|
|
225
|
-
|
|
243
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
244
|
+
return;
|
|
226
245
|
}
|
|
227
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
247
|
+
if (switching) continue; // try the next key
|
|
248
|
+
return; // clean end — served
|
|
249
|
+
} finally {
|
|
250
|
+
if (acquired && _pickedRef) {
|
|
251
|
+
concurrencyTracker.release(_pickedRef);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
// pool exhausted — all keys cooling or missing
|
package/lib/routes-ops.js
CHANGED
|
@@ -24,6 +24,7 @@ import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
|
24
24
|
import { nextQuotaReset } from './quota-window.js';
|
|
25
25
|
import { classifyFailure } from './error-taxonomy.js';
|
|
26
26
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
27
|
+
import { bestEffort } from './best-effort.js';
|
|
27
28
|
|
|
28
29
|
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
29
30
|
const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
@@ -163,6 +164,8 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
163
164
|
budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
|
|
164
165
|
budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
|
|
165
166
|
pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
|
|
167
|
+
routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
|
|
168
|
+
proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
|
|
166
169
|
});
|
|
167
170
|
} catch (e) {
|
|
168
171
|
console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
@@ -314,7 +317,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
314
317
|
st.brokenUntil?.delete(ref);
|
|
315
318
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
316
319
|
}
|
|
317
|
-
|
|
320
|
+
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
318
321
|
json(res, 200, { ok: true, ref });
|
|
319
322
|
return;
|
|
320
323
|
}
|
|
@@ -480,7 +483,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
480
483
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
481
484
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
482
485
|
let source = null;
|
|
483
|
-
|
|
486
|
+
await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
|
|
484
487
|
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
485
488
|
if (!present && testValue) { source = 'pre-save'; }
|
|
486
489
|
else if (!present) {
|
package/lib/sandbox.js
CHANGED
|
@@ -83,7 +83,15 @@ export class SandboxRunner {
|
|
|
83
83
|
res = await doFetch();
|
|
84
84
|
} catch (e) {
|
|
85
85
|
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
86
|
-
|
|
86
|
+
// ponytail: 1 retry on transient socket network errors before marking broken
|
|
87
|
+
await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
|
88
|
+
if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
89
|
+
try {
|
|
90
|
+
res = await doFetch();
|
|
91
|
+
} catch (e2) {
|
|
92
|
+
if (e2 && e2.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
93
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
94
|
+
}
|
|
87
95
|
}
|
|
88
96
|
// ponytail: 1 retry on 5xx — naive; classifier refines if needed
|
|
89
97
|
if (res.status >= 500 && res.status < 600) {
|
package/lib/webhook.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bestEffort } from './best-effort.js';
|
|
1
2
|
// lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
|
|
2
3
|
export const WEBHOOK_TIMEOUT_MS = 5000;
|
|
3
4
|
export const WEBHOOK_MIN_INTERVAL_MS = 1000;
|
|
@@ -187,7 +188,7 @@ export class AlertDebouncer {
|
|
|
187
188
|
|
|
188
189
|
const res = await this._sender.send(url, payload);
|
|
189
190
|
for (const cb of entry.callbacks) {
|
|
190
|
-
|
|
191
|
+
bestEffort('webhook.callback', () => { cb(res); }, this._logger);
|
|
191
192
|
}
|
|
192
193
|
return res;
|
|
193
194
|
}
|
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.11",
|
|
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": [
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
},
|
|
42
42
|
"client": {
|
|
43
43
|
"platform": "web",
|
|
44
|
-
"inject": [
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-locale",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
47
|
+
]
|
|
45
48
|
}
|
|
46
49
|
},
|
|
47
50
|
"license": "MIT",
|