@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/ops-metrics.js
CHANGED
|
@@ -47,6 +47,9 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
47
47
|
lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
|
|
48
48
|
lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
|
|
49
49
|
|
|
50
|
+
lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
|
|
51
|
+
lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
|
|
52
|
+
|
|
50
53
|
lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
|
|
51
54
|
lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
|
|
52
55
|
|
|
@@ -56,10 +59,11 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
56
59
|
for (const pool of pools) {
|
|
57
60
|
const p = pool.base;
|
|
58
61
|
const st = poolState.get(p);
|
|
59
|
-
let active = 0, cooling = 0, paused = 0, revoked = 0;
|
|
62
|
+
let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
|
|
60
63
|
for (const r of (pool.refs ?? [])) {
|
|
61
64
|
if (isKeyRevoked(pool, r)) { revoked++; continue; }
|
|
62
65
|
if (isKeyPaused(pool, r)) { paused++; continue; }
|
|
66
|
+
if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
|
|
63
67
|
const fu = st?.failedUntil?.get(r) ?? 0;
|
|
64
68
|
if (fu > now) cooling++;
|
|
65
69
|
else active++;
|
|
@@ -68,6 +72,7 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
68
72
|
lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
|
|
69
73
|
lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
|
|
70
74
|
lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
|
|
75
|
+
lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
|
|
71
76
|
lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
|
|
72
77
|
lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
|
|
73
78
|
}
|
package/lib/ops-status.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
|
|
2
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
3
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
2
4
|
import { json } from './http-bridge.js';
|
|
3
5
|
import {
|
|
4
6
|
isTrustedBridgeRequest,
|
|
7
|
+
isKeyPaused,
|
|
8
|
+
isKeyRevoked,
|
|
5
9
|
keyTail,
|
|
6
10
|
envValue,
|
|
7
11
|
computeHealthScore,
|
|
@@ -53,7 +57,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
53
57
|
const now = Date.now();
|
|
54
58
|
const seen = new Set();
|
|
55
59
|
const providers = [];
|
|
56
|
-
for (const pool of poolByRef.values()) {
|
|
60
|
+
for (const pool of (runtime.pools ?? poolByRef.values())) {
|
|
57
61
|
if (seen.has(pool.base)) continue;
|
|
58
62
|
seen.add(pool.base);
|
|
59
63
|
try {
|
|
@@ -86,6 +90,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
86
90
|
/* describe is optional — the card falls back to editable */
|
|
87
91
|
}
|
|
88
92
|
const until = pool.state.failedUntil.get(ref);
|
|
93
|
+
const modelQuota = getModelQuotaStatus(pool, ref, now);
|
|
89
94
|
keys.push({
|
|
90
95
|
ref,
|
|
91
96
|
present,
|
|
@@ -106,10 +111,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
106
111
|
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
107
112
|
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
108
113
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
114
|
+
paused: isKeyPaused(pool, ref),
|
|
115
|
+
revoked: isKeyRevoked(pool, ref),
|
|
116
|
+
modelQuota,
|
|
109
117
|
});
|
|
110
118
|
}
|
|
111
119
|
providers.push({
|
|
112
120
|
provider: pool.base,
|
|
121
|
+
model: pool.model ?? null,
|
|
122
|
+
quotasConfigured: hasModelQuotaConfig(pool),
|
|
113
123
|
keys,
|
|
114
124
|
tags: providerTags.get(pool.base) ?? [],
|
|
115
125
|
// #260 circuit breaker state (may be null if not yet tripped)
|
|
@@ -144,7 +154,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
144
154
|
});
|
|
145
155
|
} catch (e) {
|
|
146
156
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
147
|
-
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
157
|
+
providers.push({ provider: pool.base, model: pool.model ?? null, keys: [], statusError: String(e?.message ?? e) });
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
json(res, 200, sanitizeSnapshot({
|
|
@@ -175,23 +185,34 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
175
185
|
const now = Date.now();
|
|
176
186
|
const pools = {};
|
|
177
187
|
let exhaustedAny = false;
|
|
178
|
-
const
|
|
188
|
+
const runtimeH = buildRuntime();
|
|
179
189
|
const seenH = new Set();
|
|
180
|
-
for (const pool of
|
|
190
|
+
for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
|
|
181
191
|
if (seenH.has(pool.base)) continue;
|
|
182
192
|
seenH.add(pool.base);
|
|
193
|
+
const quotaAware = hasModelQuotaConfig(pool);
|
|
183
194
|
let healthy = 0;
|
|
195
|
+
let quotaExhausted = 0;
|
|
184
196
|
for (const ref of pool.refs) {
|
|
197
|
+
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
185
198
|
const until = pool.state.failedUntil.get(ref);
|
|
186
199
|
if (until !== undefined && until > now) continue;
|
|
187
200
|
const exp = pool.expiresAt?.[ref];
|
|
188
201
|
if (exp !== undefined && now >= exp) continue;
|
|
202
|
+
if (quotaAware && !isModelQuotaAvailable(pool, ref, now)) { quotaExhausted++; continue; }
|
|
189
203
|
healthy++;
|
|
190
204
|
}
|
|
191
205
|
const total = pool.refs.length;
|
|
192
206
|
const exhausted = healthy === 0 && total > 0;
|
|
193
207
|
if (exhausted) exhaustedAny = true;
|
|
194
|
-
pools[pool.base] = {
|
|
208
|
+
pools[pool.base] = {
|
|
209
|
+
healthy,
|
|
210
|
+
total,
|
|
211
|
+
exhausted,
|
|
212
|
+
healthScore: computeHealthScore(pool.state),
|
|
213
|
+
model: pool.model ?? null,
|
|
214
|
+
modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
|
|
215
|
+
};
|
|
195
216
|
}
|
|
196
217
|
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
|
|
197
218
|
},
|
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,5 @@
|
|
|
1
|
+
import { resetModelQuotaIfNeeded } from './model-quota.js';
|
|
2
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
3
|
import { bestEffort } from './best-effort.js';
|
|
2
4
|
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
3
5
|
// Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
|
|
@@ -11,8 +13,9 @@ export const KEY_TAIL_PLACEHOLDER = '***';
|
|
|
11
13
|
/** Mask key to show only the last KEY_TAIL_CHARS; returns placeholder for short keys (#355). */
|
|
12
14
|
export function keyTail(key) {
|
|
13
15
|
if (typeof key !== 'string' || key.length === 0) return '';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const plain = decryptSecret(key);
|
|
17
|
+
if (plain.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
|
|
18
|
+
return plain.slice(-KEY_TAIL_CHARS);
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/** Check if an IP address is a loopback address. */
|
|
@@ -23,15 +26,26 @@ export function isLoopbackAddress(ip) {
|
|
|
23
26
|
return false;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
/**
|
|
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). */
|
|
27
35
|
export function isTrustedBridgeRequest(req) {
|
|
28
36
|
const remoteAddress = req?.socket?.remoteAddress;
|
|
29
37
|
if (!isLoopbackAddress(remoteAddress)) return false;
|
|
30
|
-
const origin = req?.headers?.origin;
|
|
31
|
-
if (!origin) return false;
|
|
32
38
|
if (req?.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
33
39
|
const hostHeader = req?.headers?.host;
|
|
34
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
|
+
}
|
|
35
49
|
try {
|
|
36
50
|
const originUrl = new URL(origin);
|
|
37
51
|
if (originUrl.host !== hostHeader) return false;
|
|
@@ -187,7 +201,7 @@ export function envValue(ref) {
|
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
/** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
|
|
190
|
-
export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
204
|
+
export function sweepExpired(poolState, now = Date.now(), activeRefs = null, quotaResetWindow = null) {
|
|
191
205
|
let cleared = 0;
|
|
192
206
|
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
193
207
|
for (const st of poolState.values()) {
|
|
@@ -201,9 +215,17 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
|
201
215
|
cleared++;
|
|
202
216
|
}
|
|
203
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
|
+
}
|
|
204
226
|
// Prune stale entries for keys no longer in active configuration
|
|
205
227
|
if (activeSet) {
|
|
206
|
-
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]) {
|
|
207
229
|
if (map && typeof map.keys === 'function') {
|
|
208
230
|
for (const k of [...map.keys()]) {
|
|
209
231
|
if (!activeSet.has(k)) map.delete(k);
|
|
@@ -290,9 +312,10 @@ export function parseDurationToSeconds(v) {
|
|
|
290
312
|
/** Check if a key reference is in maintenance/paused mode. */
|
|
291
313
|
export function isKeyPaused(pool, ref) {
|
|
292
314
|
if (!pool || !ref) return false;
|
|
293
|
-
if (pool.pausedRefs instanceof Set
|
|
294
|
-
if (Array.isArray(pool.pausedRefs)
|
|
295
|
-
if (pool.state && pool.state.pausedRefs instanceof Set
|
|
315
|
+
if (pool.pausedRefs instanceof Set && pool.pausedRefs.has(ref)) return true;
|
|
316
|
+
if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
|
|
317
|
+
if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
|
|
318
|
+
if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
|
|
296
319
|
return false;
|
|
297
320
|
}
|
|
298
321
|
|