@goodandready/dsh-key-rotation 0.8.29 → 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 +28 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +33 -29
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +31 -139
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +4 -1
- package/lib/ops-status.js +21 -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 +25 -5
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/package.json +1 -1
package/lib/persistence.js
CHANGED
|
@@ -35,11 +35,25 @@ export class StatePersistence {
|
|
|
35
35
|
if (Number.isFinite(until)) failedUntil[ref] = until;
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
const tokenUsage = {};
|
|
39
|
+
if (st && st.tokenUsage && typeof st.tokenUsage.forEach === 'function') {
|
|
40
|
+
st.tokenUsage.forEach((entry, ref) => {
|
|
41
|
+
const used = Number(entry?.used);
|
|
42
|
+
const resetAt = Number(entry?.resetAt);
|
|
43
|
+
if (!Number.isFinite(used) || used < 0) return;
|
|
44
|
+
if (!Number.isFinite(resetAt) || resetAt <= 0) return;
|
|
45
|
+
tokenUsage[ref] = { used: Math.floor(used), resetAt: Math.floor(resetAt) };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const outPool = {
|
|
39
49
|
failedUntil,
|
|
40
50
|
pointer: Number.isFinite(st?.pointer) ? st.pointer : 0,
|
|
41
51
|
lastUsed: typeof st?.lastUsed === 'string' ? st.lastUsed : null,
|
|
42
52
|
};
|
|
53
|
+
if (Object.keys(tokenUsage).length > 0) {
|
|
54
|
+
outPool.tokenUsage = tokenUsage;
|
|
55
|
+
}
|
|
56
|
+
pools[base] = outPool;
|
|
43
57
|
});
|
|
44
58
|
}
|
|
45
59
|
return {
|
|
@@ -115,6 +129,16 @@ export class StatePersistence {
|
|
|
115
129
|
if (Number.isFinite(until)) state.failedUntil.set(ref, until);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
132
|
+
if (saved.tokenUsage && typeof saved.tokenUsage === 'object' && !Array.isArray(saved.tokenUsage)) {
|
|
133
|
+
for (const [ref, entry] of Object.entries(saved.tokenUsage)) {
|
|
134
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
135
|
+
const used = Number(entry.used);
|
|
136
|
+
const resetAt = Number(entry.resetAt);
|
|
137
|
+
if (!Number.isFinite(used) || used < 0) continue;
|
|
138
|
+
if (!Number.isFinite(resetAt) || resetAt <= 0) continue;
|
|
139
|
+
state.tokenUsage.set(ref, { used: Math.floor(used), resetAt: Math.floor(resetAt) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
118
142
|
state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
|
|
119
143
|
state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
|
|
120
144
|
}
|
package/lib/pool-builder.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// lib/pool-builder.js — provider & per-model pool assembly and cleanup
|
|
2
2
|
import { bucketSweep } from './bucket.js';
|
|
3
3
|
import { isValidRef } from './pool.js';
|
|
4
|
+
import { normalizeModelQuotas } from './model-quota.js';
|
|
5
|
+
import { createPoolIndex, addProviderPools } from './pool-index.js';
|
|
6
|
+
import { initializePoolState } from './pool-state.js';
|
|
4
7
|
|
|
5
8
|
export function parseExpiry(v) {
|
|
6
9
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
@@ -24,6 +27,10 @@ export function buildPoolItem({
|
|
|
24
27
|
poolGuard,
|
|
25
28
|
rpmLimit,
|
|
26
29
|
makeState,
|
|
30
|
+
provider = null,
|
|
31
|
+
model = null,
|
|
32
|
+
quotas = null,
|
|
33
|
+
quotaResetWindow = null,
|
|
27
34
|
}) {
|
|
28
35
|
const seen = new Set();
|
|
29
36
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -62,8 +69,14 @@ export function buildPoolItem({
|
|
|
62
69
|
for (let i = 0; i < refs.length; i++) {
|
|
63
70
|
weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
64
71
|
}
|
|
72
|
+
const normalizedQuotas = normalizeModelQuotas(quotas);
|
|
65
73
|
return {
|
|
66
74
|
base,
|
|
75
|
+
provider,
|
|
76
|
+
model,
|
|
77
|
+
quotas: normalizedQuotas,
|
|
78
|
+
hasModelQuota: Object.keys(normalizedQuotas).length > 0,
|
|
79
|
+
quotaResetWindow,
|
|
67
80
|
refs,
|
|
68
81
|
weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
69
82
|
weightsMap,
|
|
@@ -80,29 +93,106 @@ export function buildPoolItem({
|
|
|
80
93
|
};
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
export function buildPools({ cfg = {}, poolState = new Map(), defaultState = initializePoolState } = {}) {
|
|
97
|
+
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
98
|
+
const quotaResetWindow = cfg.quotaResetWindow ?? null;
|
|
99
|
+
const poolByRef = new Map();
|
|
100
|
+
const providerToPool = new Map();
|
|
101
|
+
const modelPoolByProvider = new Map();
|
|
102
|
+
const cloneIds = new Set();
|
|
103
|
+
const index = createPoolIndex();
|
|
104
|
+
|
|
105
|
+
const makeState = (base) => {
|
|
106
|
+
let st = poolState.get(base);
|
|
107
|
+
if (!st) {
|
|
108
|
+
st = initializePoolState(defaultState());
|
|
109
|
+
poolState.set(base, st);
|
|
110
|
+
}
|
|
111
|
+
return initializePoolState(st);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const buildPool = (base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, meta) =>
|
|
115
|
+
buildPoolItem({
|
|
116
|
+
base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard,
|
|
117
|
+
rpmLimit, makeState, quotaResetWindow, ...meta,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const p of cfg.providers ?? []) {
|
|
121
|
+
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
122
|
+
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
123
|
+
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
124
|
+
provider: p.provider,
|
|
125
|
+
model: null,
|
|
126
|
+
});
|
|
127
|
+
if (pool) {
|
|
128
|
+
for (const ref of pool.refs) {
|
|
129
|
+
if (!poolByRef.has(ref)) poolByRef.set(ref, pool);
|
|
130
|
+
}
|
|
131
|
+
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
132
|
+
if (!providerToPool.has(p.provider)) providerToPool.set(p.provider, pool);
|
|
133
|
+
}
|
|
134
|
+
const byModel = new Map();
|
|
135
|
+
for (const [model, mp] of Object.entries(p.models ?? {})) {
|
|
136
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, mp.revoked, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, {
|
|
137
|
+
provider: p.provider,
|
|
138
|
+
model,
|
|
139
|
+
quotas: mp.quotas,
|
|
140
|
+
});
|
|
141
|
+
if (!mpool) continue;
|
|
142
|
+
byModel.set(model, mpool);
|
|
143
|
+
for (const ref of mpool.refs) {
|
|
144
|
+
if (!poolByRef.has(ref)) poolByRef.set(ref, mpool);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (byModel.size > 0) modelPoolByProvider.set(p.provider, byModel);
|
|
148
|
+
addProviderPools(index, p.provider, pool, byModel);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
index,
|
|
153
|
+
pools: [...index.allPools.values()],
|
|
154
|
+
poolByRef,
|
|
155
|
+
providerToPool,
|
|
156
|
+
modelPoolByProvider,
|
|
157
|
+
cloneIds,
|
|
158
|
+
poolState,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
83
162
|
export function cleanupRemovedProviders({
|
|
84
163
|
cfg,
|
|
85
164
|
poolState,
|
|
86
165
|
poolByRef,
|
|
166
|
+
pools,
|
|
87
167
|
providerToPool,
|
|
88
168
|
expectedClones,
|
|
89
169
|
moduleBreaker,
|
|
90
170
|
lowHealthNotifiedAt,
|
|
91
171
|
budgetNotifiedAt,
|
|
92
172
|
}) {
|
|
93
|
-
|
|
173
|
+
const live = Array.isArray(pools) ? pools : [...(pools?.values?.() ?? (poolByRef?.values?.() ? [...poolByRef.values()] : []))];
|
|
174
|
+
const liveBases = new Set(live.map((p) => p.base));
|
|
94
175
|
for (const key of [...poolState.keys()]) {
|
|
95
|
-
if (!
|
|
176
|
+
if (!liveBases.has(key)) {
|
|
96
177
|
poolState.delete(key);
|
|
97
178
|
lowHealthNotifiedAt?.delete?.(key);
|
|
98
|
-
budgetNotifiedAt?.delete?.(key
|
|
179
|
+
budgetNotifiedAt?.delete?.(`${key}:budget`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const liveRefs = new Set();
|
|
183
|
+
for (const pool of live) {
|
|
184
|
+
if (Array.isArray(pool?.refs)) {
|
|
185
|
+
for (const ref of pool.refs) liveRefs.add(ref);
|
|
99
186
|
}
|
|
100
187
|
}
|
|
101
|
-
// #192: drop RPM windows for refs that no longer belong to any pool
|
|
102
188
|
for (const st of poolState.values()) {
|
|
103
|
-
if (st.rpmWindows) bucketSweep(st.rpmWindows,
|
|
189
|
+
if (st.rpmWindows) bucketSweep(st.rpmWindows, liveRefs);
|
|
190
|
+
if (st.tokenUsage instanceof Map) {
|
|
191
|
+
for (const ref of [...st.tokenUsage.keys()]) {
|
|
192
|
+
if (!liveRefs.has(ref)) st.tokenUsage.delete(ref);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
104
195
|
}
|
|
105
|
-
// drop breaker entries for removed providers
|
|
106
196
|
if (moduleBreaker) {
|
|
107
197
|
for (const key of Object.keys(moduleBreaker.snapshot())) {
|
|
108
198
|
if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// lib/pool-index.js — which pool owns a credential ref for a given request.
|
|
2
|
+
//
|
|
3
|
+
// One credential ref can legitimately live in several pools at once:
|
|
4
|
+
//
|
|
5
|
+
// anthropic::claude-sonnet -> CLAUDE_KEY_A, CLAUDE_KEY_B
|
|
6
|
+
// anthropic::claude-opus -> CLAUDE_KEY_A, CLAUDE_KEY_B
|
|
7
|
+
//
|
|
8
|
+
// A single `Map<ref, pool>` therefore cannot answer "which pool is this request
|
|
9
|
+
// using?" — the last pool written wins and every other model pool becomes
|
|
10
|
+
// invisible. The runtime keeps instead:
|
|
11
|
+
//
|
|
12
|
+
// allPools Map<poolBase, pool> every pool, base and per-model
|
|
13
|
+
// basePoolByRef Map<ref, basePool> provider-base ownership only
|
|
14
|
+
// modelPoolsByProvider Map<provider, Map<model, pool>>
|
|
15
|
+
// modelPoolByRef Map<ref, Set<modelPool>> every model pool a ref appears in
|
|
16
|
+
//
|
|
17
|
+
// `poolByRef` still exists for single-pool compatibility lookups, but it is no
|
|
18
|
+
// longer the enumeration source for persistence, status, health or sweeps.
|
|
19
|
+
|
|
20
|
+
/** Provider id of a pool base (`anthropic::claude-opus` -> `anthropic`). */
|
|
21
|
+
export function providerOfBase(base) {
|
|
22
|
+
if (typeof base !== 'string') return '';
|
|
23
|
+
const at = base.indexOf('::');
|
|
24
|
+
return at === -1 ? base : base.slice(0, at);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build an empty index. Call `addProvider()` once per configured provider.
|
|
29
|
+
* @returns {object} index consumed by `resolveRefPool()` / `allPools()`
|
|
30
|
+
*/
|
|
31
|
+
export function createPoolIndex() {
|
|
32
|
+
const index = {
|
|
33
|
+
// Complete pool inventory, keyed by pool base, so no pool is ever lost to
|
|
34
|
+
// a ref collision. Iterate `allPools`, never `poolByRef`, to enumerate.
|
|
35
|
+
allPools: new Map(),
|
|
36
|
+
basePoolByRef: new Map(),
|
|
37
|
+
modelPoolsByProvider: new Map(),
|
|
38
|
+
modelPoolByRef: new Map(),
|
|
39
|
+
basePoolByProvider: new Map(),
|
|
40
|
+
};
|
|
41
|
+
return index;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Register one provider's base pool and its `model -> pool` sub-pools. */
|
|
45
|
+
export function addProviderPools(index, provider, basePool, modelPools) {
|
|
46
|
+
if (basePool) {
|
|
47
|
+
index.allPools.set(basePool.base, basePool);
|
|
48
|
+
index.basePoolByProvider.set(provider, basePool);
|
|
49
|
+
for (const ref of basePool.refs) {
|
|
50
|
+
if (!index.basePoolByRef.has(ref)) index.basePoolByRef.set(ref, basePool);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!modelPools || modelPools.size === 0) return;
|
|
54
|
+
index.modelPoolsByProvider.set(provider, modelPools);
|
|
55
|
+
for (const pool of modelPools.values()) {
|
|
56
|
+
index.allPools.set(pool.base, pool);
|
|
57
|
+
for (const ref of pool.refs) {
|
|
58
|
+
let set = index.modelPoolByRef.get(ref);
|
|
59
|
+
if (!set) { set = new Set(); index.modelPoolByRef.set(ref, set); }
|
|
60
|
+
set.add(pool);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every pool of the index, deduplicated by base. */
|
|
66
|
+
export function allPools(index) {
|
|
67
|
+
return [...index.allPools.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Provider-base pool for a pool of any depth (base pool returns itself). */
|
|
71
|
+
export function basePoolOf(index, pool) {
|
|
72
|
+
if (!pool) return null;
|
|
73
|
+
if (!pool.model) return pool;
|
|
74
|
+
return index.basePoolByProvider.get(pool.provider) ?? pool;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The provider-base pool that a pool base belongs to, or null. */
|
|
78
|
+
export function basePoolForBase(index, base) {
|
|
79
|
+
const own = index.allPools.get(base);
|
|
80
|
+
if (!own) return null;
|
|
81
|
+
return basePoolOf(index, own);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pool that owns `ref` for the request currently being dispatched.
|
|
86
|
+
*
|
|
87
|
+
* `requested` is the request-scoped pool chosen for this LLM call (set by
|
|
88
|
+
* rotate() in the dispatch AsyncLocalStorage). It is authoritative: a Sonnet
|
|
89
|
+
* request must rotate inside the Sonnet pool even when the incoming ref is the
|
|
90
|
+
* provider profile's original `apiKeyEnv`, because replacing that key with the
|
|
91
|
+
* model-specific key pool is the entire point of a model sub-pool.
|
|
92
|
+
*
|
|
93
|
+
* The one thing a request-scoped pool must not do is hijack an unrelated
|
|
94
|
+
* credential resolved during the same request, so substitution is limited to
|
|
95
|
+
* refs that belong to this pool or to its provider-base pool:
|
|
96
|
+
*
|
|
97
|
+
* ref in requested.refs -> requested (already a model-pool key)
|
|
98
|
+
* ref in provider base pool refs -> requested (substitute the model pool)
|
|
99
|
+
* provider has no base pool -> requested (model pool is the only source)
|
|
100
|
+
* anything else -> null (plain original resolve)
|
|
101
|
+
*
|
|
102
|
+
* With no request-scoped pool (ordinary `credentials.resolve()` outside
|
|
103
|
+
* rotation) the global lookup runs, preferring the provider-base pool.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} index result of createPoolIndex()
|
|
106
|
+
* @param {string} ref credential ref being resolved
|
|
107
|
+
* @param {object|null|undefined} requested request-scoped pool
|
|
108
|
+
* @returns {object|null} pool to rotate within, or null to use the original resolve
|
|
109
|
+
*/
|
|
110
|
+
export function resolveRefPool(index, ref, requested) {
|
|
111
|
+
if (typeof ref !== 'string' || ref.length === 0) return null;
|
|
112
|
+
if (requested) {
|
|
113
|
+
if (Array.isArray(requested.refs) && requested.refs.includes(ref)) return requested;
|
|
114
|
+
if (!requested.model) return null;
|
|
115
|
+
const base = index.basePoolByProvider.get(requested.provider);
|
|
116
|
+
if (!base) return requested;
|
|
117
|
+
return base.refs.includes(ref) ? requested : null;
|
|
118
|
+
}
|
|
119
|
+
const base = index.basePoolByRef.get(ref);
|
|
120
|
+
if (base) return base;
|
|
121
|
+
const set = index.modelPoolByRef.get(ref);
|
|
122
|
+
return set && set.size > 0 ? set.values().next().value : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* True when the resolver may still rotate for this ref even though every
|
|
127
|
+
* candidate is locally out of budget. Quota enforcement is fail-closed only for
|
|
128
|
+
* requests this plugin actually manages; an ordinary `credentials.resolve()`
|
|
129
|
+
* outside rotation keeps its historical behaviour.
|
|
130
|
+
*/
|
|
131
|
+
export function isQuotaManagedRequest(index, ref, requested) {
|
|
132
|
+
if (!requested) return false;
|
|
133
|
+
return resolveRefPool(index, ref, requested) === requested;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Model pools a ref appears in. Used by ops routes to describe one credential
|
|
138
|
+
* across the models it serves without guessing from the pool base string.
|
|
139
|
+
*/
|
|
140
|
+
export function modelPoolsForRef(index, ref) {
|
|
141
|
+
const set = index.modelPoolByRef.get(ref);
|
|
142
|
+
return set ? [...set] : [];
|
|
143
|
+
}
|
package/lib/pool-state.js
CHANGED
package/lib/pool.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
1
2
|
import { decryptSecret } from './crypto-storage.js';
|
|
2
3
|
import { bestEffort } from './best-effort.js';
|
|
3
4
|
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
@@ -25,15 +26,26 @@ export function isLoopbackAddress(ip) {
|
|
|
25
26
|
return false;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
+
/** Strip an optional :port and IPv6 brackets from a Host header value. */
|
|
30
|
+
function hostNameOf(hostHeader) {
|
|
31
|
+
return String(hostHeader).replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Check if an incoming HTTP request is from a trusted local bridge origin (#353 fail-closed, #378 method-aware). */
|
|
29
35
|
export function isTrustedBridgeRequest(req) {
|
|
30
36
|
const remoteAddress = req?.socket?.remoteAddress;
|
|
31
37
|
if (!isLoopbackAddress(remoteAddress)) return false;
|
|
32
|
-
const origin = req?.headers?.origin;
|
|
33
|
-
if (!origin) return false;
|
|
34
38
|
if (req?.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
35
39
|
const hostHeader = req?.headers?.host;
|
|
36
40
|
if (!hostHeader) return false;
|
|
41
|
+
const hostName = hostNameOf(hostHeader);
|
|
42
|
+
if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
|
|
43
|
+
|
|
44
|
+
const origin = req?.headers?.origin;
|
|
45
|
+
if (!origin) {
|
|
46
|
+
const method = String(req?.method ?? 'GET').toUpperCase();
|
|
47
|
+
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
|
|
48
|
+
}
|
|
37
49
|
try {
|
|
38
50
|
const originUrl = new URL(origin);
|
|
39
51
|
if (originUrl.host !== hostHeader) return false;
|
|
@@ -189,7 +201,7 @@ export function envValue(ref) {
|
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
/** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
|
|
192
|
-
export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
204
|
+
export function sweepExpired(poolState, now = Date.now(), activeRefs = null, quotaResetWindow = null) {
|
|
193
205
|
let cleared = 0;
|
|
194
206
|
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
195
207
|
for (const st of poolState.values()) {
|
|
@@ -203,9 +215,17 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
|
203
215
|
cleared++;
|
|
204
216
|
}
|
|
205
217
|
}
|
|
218
|
+
// Local model token budgets roll over on their own wall-clock window, never
|
|
219
|
+
// on a cooldown. Recomputing the boundary here keeps a long-lived idle pool
|
|
220
|
+
// from carrying an expired resetAt until its next request.
|
|
221
|
+
if (st.tokenUsage instanceof Map && quotaResetWindow) {
|
|
222
|
+
for (const ref of [...st.tokenUsage.keys()]) {
|
|
223
|
+
resetModelQuotaIfNeeded({ state: st, quotaResetWindow }, ref, quotaResetWindow, now);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
206
226
|
// Prune stale entries for keys no longer in active configuration
|
|
207
227
|
if (activeSet) {
|
|
208
|
-
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]) {
|
|
228
|
+
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, st.tokenUsage]) {
|
|
209
229
|
if (map && typeof map.keys === 'function') {
|
|
210
230
|
for (const k of [...map.keys()]) {
|
|
211
231
|
if (!activeSet.has(k)) map.delete(k);
|
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
|
+
}
|