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