@goodandready/dsh-key-rotation 0.8.28 → 0.8.30
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 +49 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +35 -30
- package/lib/cascade.js +3 -9
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +35 -128
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +8 -1
- package/lib/ops-metrics.js +6 -1
- package/lib/ops-status.js +26 -5
- package/lib/persistence.js +25 -1
- package/lib/pool-builder.js +96 -6
- package/lib/pool-index.js +143 -0
- package/lib/pool-state.js +1 -0
- package/lib/pool.js +33 -10
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/lib/webhook.js +9 -1
- package/package.json +1 -1
package/lib/resolver.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// lib/resolver.js — the credential selector behind credentials.resolve (#381).
|
|
2
|
+
//
|
|
3
|
+
// Extracted from lib/index.js so the routing decisions that this feature
|
|
4
|
+
// depends on — request-scoped pool ownership, model token quota enforcement,
|
|
5
|
+
// fail-closed exhaustion, paused/revoked filtering, at-rest decryption —
|
|
6
|
+
// are unit-testable without the cordis runtime.
|
|
7
|
+
//
|
|
8
|
+
// The resolver decides *which* credential ref to hand to the underlying
|
|
9
|
+
// credentials service. It never sees or returns a secret value itself; it
|
|
10
|
+
// delegates to `original(ref)`.
|
|
11
|
+
|
|
12
|
+
import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
13
|
+
import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
14
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
15
|
+
import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
|
|
16
|
+
import {
|
|
17
|
+
isModelQuotaAvailable,
|
|
18
|
+
hasModelQuotaConfig,
|
|
19
|
+
anyModelQuotaAvailable,
|
|
20
|
+
formatModelQuotaExhaustion,
|
|
21
|
+
} from './model-quota.js';
|
|
22
|
+
|
|
23
|
+
/** Internal failure code for "local budgets forbid this dispatch". */
|
|
24
|
+
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} deps
|
|
28
|
+
* @param {() => object} deps.buildRuntime current runtime snapshot
|
|
29
|
+
* @param {() => object|undefined} deps.currentPool request-scoped pool (ALS)
|
|
30
|
+
* @param {(pool: object, candidate: string) => void} [deps.onPicked] picked-ref hook
|
|
31
|
+
* @param {object} [deps.latencyHistogram]
|
|
32
|
+
* @param {object} [deps.concurrencyTracker]
|
|
33
|
+
* @param {() => number} [deps.now] wall clock; injectable for tests
|
|
34
|
+
* @returns {(ref: string, original: Function) => Promise<any>}
|
|
35
|
+
*/
|
|
36
|
+
export function createResolver({
|
|
37
|
+
buildRuntime,
|
|
38
|
+
currentPool,
|
|
39
|
+
onPicked,
|
|
40
|
+
latencyHistogram,
|
|
41
|
+
concurrencyTracker,
|
|
42
|
+
now = Date.now,
|
|
43
|
+
}) {
|
|
44
|
+
return async function resolve(ref, original) {
|
|
45
|
+
const runtime = buildRuntime();
|
|
46
|
+
// A ref may belong to several model pools at once, so the request-scoped
|
|
47
|
+
// pool that rotate() actually dispatched with decides ownership. A bare
|
|
48
|
+
// resolve outside rotation falls back to the global lookup.
|
|
49
|
+
const requested = currentPool ? currentPool() ?? null : null;
|
|
50
|
+
const pool = resolveRefPool(runtime.index, ref, requested);
|
|
51
|
+
if (!pool) {
|
|
52
|
+
const res = await original(ref);
|
|
53
|
+
if (res && typeof res.value === 'string') {
|
|
54
|
+
return { ...res, value: decryptSecret(res.value) };
|
|
55
|
+
}
|
|
56
|
+
const ev = envValue(ref);
|
|
57
|
+
if (ev !== undefined) return { value: decryptSecret(ev), source: 'env' };
|
|
58
|
+
return res;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const at = now();
|
|
62
|
+
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
63
|
+
let list = pool.weightedRefs ?? pool.refs;
|
|
64
|
+
if (strategy === 'lowest-latency' || strategy === 'least-loaded') {
|
|
65
|
+
list = sortAttemptList(list, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
66
|
+
}
|
|
67
|
+
// Fail closed: when this request is managed by a model pool that has local
|
|
68
|
+
// token budgets, an exhausted candidate may not be dispatched and the
|
|
69
|
+
// original credential must never be used as a bypass. Decided once, before
|
|
70
|
+
// the loop, so it cannot be re-evaluated mid-iteration.
|
|
71
|
+
const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
|
|
72
|
+
const start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < list.length; i++) {
|
|
75
|
+
const index = (start + i) % list.length;
|
|
76
|
+
const candidate = list[index];
|
|
77
|
+
if (isKeyRevoked(pool, candidate)) continue;
|
|
78
|
+
if (isKeyPaused(pool, candidate)) continue;
|
|
79
|
+
const until = pool.state.failedUntil.get(candidate);
|
|
80
|
+
if (until !== undefined && until > at) continue;
|
|
81
|
+
if (pool.expiresAt?.[candidate] !== undefined && at >= pool.expiresAt[candidate]) continue;
|
|
82
|
+
// Local model token budget: a spent credential is simply not a candidate.
|
|
83
|
+
// This is deliberately NOT recorded as a failure — the key is healthy and
|
|
84
|
+
// merely out of budget — so it must never touch failedUntil / failCounts /
|
|
85
|
+
// authFailCounts / brokenUntil.
|
|
86
|
+
if (quotaManaged && !isModelQuotaAvailable(pool, candidate, at)) continue;
|
|
87
|
+
const rpmLimit = pool.rpmLimit ?? 0;
|
|
88
|
+
if (rpmLimit > 0) {
|
|
89
|
+
if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
|
|
90
|
+
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, at)) {
|
|
91
|
+
const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, at);
|
|
92
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < at + waitMs) {
|
|
93
|
+
pool.state.failedUntil.set(candidate, at + waitMs);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (pool.perHour) {
|
|
99
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
100
|
+
let win = pool.state.quotaWindows.get(candidate);
|
|
101
|
+
if (!win || at - win.start >= 3600000) win = { count: 0, start: at };
|
|
102
|
+
if (win.count >= pool.perHour) {
|
|
103
|
+
const resetAt = win.start + 3600000;
|
|
104
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < resetAt) pool.state.failedUntil.set(candidate, resetAt);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Advance the shared cursor at selection time: concurrent requests must
|
|
110
|
+
// not all pick the same slot.
|
|
111
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
112
|
+
const hit = await original(candidate);
|
|
113
|
+
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
114
|
+
settle(pool, candidate, at, onPicked);
|
|
115
|
+
return { ...hit, value: decryptSecret(hit.value) };
|
|
116
|
+
}
|
|
117
|
+
// No stored credential: fall back to the launching environment, which is
|
|
118
|
+
// how env-bootstrapped pools work (issue #7).
|
|
119
|
+
const envVal = envValue(candidate);
|
|
120
|
+
if (envVal !== undefined) {
|
|
121
|
+
settle(pool, candidate, at, onPicked);
|
|
122
|
+
return { value: decryptSecret(envVal), source: 'env' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Every candidate was skipped. If the reason was local budget exhaustion,
|
|
127
|
+
// returning original(ref) would hand out a credential the budget forbids, so
|
|
128
|
+
// refuse instead of bypassing the quota. rotate() recognises this error as a
|
|
129
|
+
// local refusal: it is not penalised and not classified as an upstream failure.
|
|
130
|
+
if (quotaManaged && !anyModelQuotaAvailable(pool, at)) {
|
|
131
|
+
throw Object.assign(
|
|
132
|
+
new Error(formatModelQuotaExhaustion(pool.provider ?? pool.base, pool)),
|
|
133
|
+
{ code: LOCAL_QUOTA_CODE, localQuota: true },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return original(ref);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Record a successful credential selection on the pool and request scope. */
|
|
141
|
+
function settle(pool, candidate, now, onPicked) {
|
|
142
|
+
pool.state.lastUsed = candidate;
|
|
143
|
+
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
144
|
+
pool.state.lastUsedAt.set(candidate, now);
|
|
145
|
+
onPicked?.(pool, candidate);
|
|
146
|
+
pool.state.failCounts?.delete(candidate);
|
|
147
|
+
pool.state.failedUntil.delete(candidate);
|
|
148
|
+
pool.state.authFailCounts?.delete(candidate);
|
|
149
|
+
pool.state.brokenUntil?.delete(candidate);
|
|
150
|
+
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
151
|
+
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
152
|
+
if (pool.perHour) {
|
|
153
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
154
|
+
let win = pool.state.quotaWindows.get(candidate);
|
|
155
|
+
if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
|
|
156
|
+
win.count++;
|
|
157
|
+
pool.state.quotaWindows.set(candidate, win);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/lib/rotate.js
CHANGED
|
@@ -16,6 +16,34 @@ import {
|
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
|
|
18
18
|
import { nextQuotaReset } from './quota-window.js';
|
|
19
|
+
import {
|
|
20
|
+
isModelQuotaAvailable,
|
|
21
|
+
hasModelQuotaConfig,
|
|
22
|
+
anyModelQuotaAvailable,
|
|
23
|
+
consumeModelTokens,
|
|
24
|
+
extractUsageTokens,
|
|
25
|
+
formatModelQuotaExhaustion,
|
|
26
|
+
} from './model-quota.js';
|
|
27
|
+
|
|
28
|
+
export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
|
|
29
|
+
|
|
30
|
+
export async function* preserveDispatchContext(iterable, storage, store) {
|
|
31
|
+
if (!storage || typeof storage.getStore !== 'function') {
|
|
32
|
+
yield* iterable;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
36
|
+
const step = (method, arg) => storage.run(store, () => iterator[method](arg));
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
const next = await step('next');
|
|
40
|
+
if (next.done) return next.value;
|
|
41
|
+
yield next.value;
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
if (typeof iterator.return === 'function') await step('return');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
19
47
|
|
|
20
48
|
/**
|
|
21
49
|
* Create the rotate(options, pool) async generator used by llm/stream.
|
|
@@ -53,8 +81,44 @@ export function createRotate(deps) {
|
|
|
53
81
|
let activePermit = null;
|
|
54
82
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
55
83
|
setRotateStartMs(reqStore.startMs);
|
|
84
|
+
const wallNow = Date.now();
|
|
85
|
+
const quotaManaged = hasModelQuotaConfig(pool);
|
|
56
86
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
57
87
|
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
|
|
88
|
+
if (quotaManaged) {
|
|
89
|
+
attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
|
|
90
|
+
if (attemptList.length === 0) {
|
|
91
|
+
pool.state.lastExhaustionAt = now();
|
|
92
|
+
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
93
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget exhausted for all ${pool.refs.length} key(s) — no upstream request sent`);
|
|
94
|
+
const runtimeQ = buildRuntime();
|
|
95
|
+
notifyExhaustion(runtimeQ, pool, { provider: options.provider });
|
|
96
|
+
const maxDepth = runtimeQ.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
|
|
97
|
+
const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
|
|
98
|
+
if (currentDepth < maxDepth && Array.isArray(runtimeQ.cascade) && runtimeQ.cascade.length > 0) {
|
|
99
|
+
const fb = pickCascadeFallback(options.provider, runtimeQ, runtimeQ.providerToPool, options.model);
|
|
100
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
101
|
+
pool.state.lastReason = 'CASCADE';
|
|
102
|
+
pool.state.lastSwitchAt = now();
|
|
103
|
+
pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
|
|
104
|
+
pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
|
|
105
|
+
const innerCascade = rotate({
|
|
106
|
+
...options,
|
|
107
|
+
provider: fb.provider,
|
|
108
|
+
model: fb.model || options.model,
|
|
109
|
+
__isCascade: true,
|
|
110
|
+
__cascadeDepth: currentDepth + 1,
|
|
111
|
+
}, fb.pool);
|
|
112
|
+
for await (const chunk of innerCascade) {
|
|
113
|
+
yield chunk;
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
yield finishError(LOCAL_QUOTA_CODE, formatModelQuotaExhaustion(options.provider, pool));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
58
122
|
const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
|
|
59
123
|
if (strategy !== 'round-robin') {
|
|
60
124
|
attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
|
|
@@ -80,6 +144,7 @@ export function createRotate(deps) {
|
|
|
80
144
|
|
|
81
145
|
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
82
146
|
if (!targetRef) return;
|
|
147
|
+
if (errCode === LOCAL_QUOTA_CODE) return;
|
|
83
148
|
const _retry = parseRetryAfter(errMsg);
|
|
84
149
|
const _base = pool.cooldownMs ?? cooldownMs;
|
|
85
150
|
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
@@ -144,6 +209,11 @@ export function createRotate(deps) {
|
|
|
144
209
|
return llm.stream({ ...options, [MARKER]: true });
|
|
145
210
|
});
|
|
146
211
|
} catch (e) {
|
|
212
|
+
if (e?.localQuota || e?.code === LOCAL_QUOTA_CODE) {
|
|
213
|
+
lastFailure = finishError(LOCAL_QUOTA_CODE, String(e?.message ?? ''));
|
|
214
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget blocks dispatch — no upstream request sent`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
147
217
|
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
148
218
|
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
149
219
|
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
@@ -156,7 +226,7 @@ export function createRotate(deps) {
|
|
|
156
226
|
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
157
227
|
try {
|
|
158
228
|
try {
|
|
159
|
-
for await (const chunk of inner) {
|
|
229
|
+
for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
|
|
160
230
|
// Only actual content deltas lock the stream (no more rotation).
|
|
161
231
|
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
162
232
|
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
@@ -232,6 +302,20 @@ export function createRotate(deps) {
|
|
|
232
302
|
if (activeRef) {
|
|
233
303
|
recordSuccess(pool, activeRef, now());
|
|
234
304
|
}
|
|
305
|
+
if (activeRef && quotaManaged) {
|
|
306
|
+
const charged = consumeModelTokens(
|
|
307
|
+
pool, activeRef, extractUsageTokens(chunk.usage),
|
|
308
|
+
runtime0.quotaResetWindow, Date.now(),
|
|
309
|
+
);
|
|
310
|
+
if (charged) {
|
|
311
|
+
if (typeof schedulePersist === 'function') {
|
|
312
|
+
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
313
|
+
}
|
|
314
|
+
if (charged.exhausted) {
|
|
315
|
+
logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: key ${activeRef} reached its local token budget (${charged.used}/${charged.limit}) — excluded from this model only`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
235
319
|
|
|
236
320
|
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
237
321
|
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
package/lib/webhook.js
CHANGED
|
@@ -81,8 +81,16 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
if (platform === 'ntfy') {
|
|
84
|
+
let topic = 'alerts';
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(url);
|
|
87
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
88
|
+
if (parts.length > 0) topic = parts[parts.length - 1];
|
|
89
|
+
} catch (_) {
|
|
90
|
+
topic = String(url).split('?')[0].split('/').filter(Boolean).pop() || 'alerts';
|
|
91
|
+
}
|
|
84
92
|
return {
|
|
85
|
-
topic
|
|
93
|
+
topic,
|
|
86
94
|
title,
|
|
87
95
|
message: text,
|
|
88
96
|
priority: 4,
|
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.30",
|
|
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": [
|