@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.
@@ -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,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
- /** 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). */
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);
@@ -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
+ }