@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.
@@ -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 { poolByRef: pr, providerTags } = buildRuntime();
188
+ const runtimeH = buildRuntime();
179
189
  const seenH = new Set();
180
- for (const pool of pr.values()) {
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] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
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
  },
@@ -35,11 +35,25 @@ export class StatePersistence {
35
35
  if (Number.isFinite(until)) failedUntil[ref] = until;
36
36
  });
37
37
  }
38
- pools[base] = {
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
  }
@@ -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
- // auto-cleanup: remove poolState for providers that are now empty or removed
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 (![...poolByRef.values()].some((p) => p.base === key)) {
176
+ if (!liveBases.has(key)) {
96
177
  poolState.delete(key);
97
178
  lowHealthNotifiedAt?.delete?.(key);
98
- budgetNotifiedAt?.delete?.(key + ':budget');
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, new Set(poolByRef.keys()));
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
@@ -3,6 +3,7 @@
3
3
  const MAP_FIELDS = [
4
4
  'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
5
5
  'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
6
+ 'tokenUsage',
6
7
  ];
7
8
 
8
9
  export function initializePoolState(state = {}) {
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
- if (key.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
15
- return key.slice(-KEY_TAIL_CHARS);
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
- /** Check if an incoming HTTP request is from a trusted local bridge origin (#353 fail-closed). */
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) return pool.pausedRefs.has(ref);
294
- if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
295
- if (pool.state && pool.state.pausedRefs instanceof Set) return pool.state.pausedRefs.has(ref);
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