@goodandready/dsh-key-rotation 0.8.73 → 0.8.75

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.
@@ -224,7 +224,7 @@ export function registerConfigIoRoutes(ctx, deps) {
224
224
  }
225
225
  }
226
226
 
227
- const settings = ctx.get('settings');
227
+ const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
228
228
  let currentConfig = {};
229
229
  try {
230
230
  if (settings && typeof settings.get === 'function') {
@@ -322,28 +322,54 @@ export function registerConfigIoRoutes(ctx, deps) {
322
322
  const mode = body.mode === 'replace' ? 'replace' : 'merge';
323
323
  const dryRun = Boolean(body.dryRun);
324
324
 
325
- const settings = ctx.get('settings');
325
+ const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
326
+ if (!settings || typeof settings.replace !== 'function') {
327
+ // Fail closed when settings service is missing or lacks replace (#533)
328
+ json(res, 503, { error: { code: 'settings-unavailable', message: 'Settings service is not available or does not support writing configuration' } });
329
+ return;
330
+ }
331
+
326
332
  let currentConfig = {};
333
+ let currentRevision = null;
327
334
  try {
328
- if (settings && typeof settings.get === 'function') {
329
- currentConfig = settings.get(NS) || {};
330
- } else if (settings && typeof settings.describe === 'function') {
335
+ if (typeof settings.describe === 'function') {
331
336
  const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
332
- currentConfig = desc?.value || {};
333
- } else {
334
- currentConfig = deps.buildRuntime().cfg0 || {};
337
+ if (desc) {
338
+ currentConfig = desc.value || {};
339
+ currentRevision = desc.revision ?? null;
340
+ }
341
+ }
342
+ if (!currentConfig || Object.keys(currentConfig).length === 0) {
343
+ if (typeof settings.get === 'function') {
344
+ currentConfig = settings.get(NS) || {};
345
+ }
335
346
  }
336
347
  } catch (_) {
337
348
  currentConfig = deps.buildRuntime().cfg0 || {};
338
349
  }
339
350
  const rawCurrent = plainConfig(currentConfig);
340
351
 
352
+ // Concurrency control: if client provided expectedRevision, reject if stale (#553)
353
+ if (typeof body.expectedRevision === 'number' && currentRevision !== null) {
354
+ if (body.expectedRevision !== currentRevision) {
355
+ json(res, 409, {
356
+ error: {
357
+ code: 'conflict',
358
+ message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
359
+ currentRevision,
360
+ }
361
+ });
362
+ return;
363
+ }
364
+ }
365
+
341
366
  const preview = diffConfigs(rawCurrent, incomingConfig, mode);
342
367
 
343
368
  if (dryRun) {
344
369
  json(res, 200, {
345
370
  ok: true,
346
371
  dryRun: true,
372
+ revision: currentRevision,
347
373
  preview,
348
374
  });
349
375
  return;
@@ -379,19 +405,18 @@ export function registerConfigIoRoutes(ctx, deps) {
379
405
  mergedConfig.providers = curProviders;
380
406
  }
381
407
 
382
- if (settings && typeof settings.replace === 'function') {
383
- try {
384
- await settings.replace(NS, mergedConfig);
385
- } catch (err) {
386
- json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
387
- return;
388
- }
408
+ try {
409
+ await settings.replace(NS, mergedConfig, currentRevision ?? undefined);
410
+ } catch (err) {
411
+ json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
412
+ return;
389
413
  }
390
414
 
391
415
  json(res, 200, {
392
416
  ok: true,
393
417
  applied: true,
394
418
  mode,
419
+ revision: typeof currentRevision === 'number' ? currentRevision + 1 : undefined,
395
420
  preview,
396
421
  });
397
422
  };
package/lib/ops-keys.js CHANGED
@@ -165,17 +165,18 @@ ctx.effect(() => ctx.webServer.register({
165
165
  return;
166
166
  }
167
167
  const concurrency = deps.concurrencyTracker;
168
+ const keyDrainManager = deps.keyDrainManager;
168
169
 
169
170
  if (req.method === 'GET') {
170
171
  const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
171
172
  const ref = url.searchParams.get('ref');
172
173
  if (ref) {
173
- const status = getDrainingStatus(ref, concurrency);
174
+ const status = getDrainingStatus(ref, concurrency, keyDrainManager);
174
175
  if (!status) { json(res, 404, { error: { code: 'not-found', message: `Key ${ref} is not currently draining` } }); return; }
175
176
  json(res, 200, { ok: true, draining: status });
176
177
  return;
177
178
  }
178
- json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency) });
179
+ json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency, keyDrainManager) });
179
180
  return;
180
181
  }
181
182
 
@@ -187,8 +188,8 @@ ctx.effect(() => ctx.webServer.register({
187
188
  }
188
189
  const ref = body?.ref;
189
190
  if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref' } }); return; }
190
- markKeyDraining(ref, body?.provider || '');
191
- json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency) });
191
+ markKeyDraining(ref, body?.provider || '', keyDrainManager);
192
+ json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency, keyDrainManager) });
192
193
  return;
193
194
  }
194
195
 
@@ -196,7 +197,7 @@ ctx.effect(() => ctx.webServer.register({
196
197
  const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
197
198
  const ref = url.searchParams.get('ref');
198
199
  if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref query parameter' } }); return; }
199
- const cancelled = cancelKeyDrain(ref);
200
+ const cancelled = cancelKeyDrain(ref, keyDrainManager);
200
201
  json(res, 200, { ok: true, cancelled });
201
202
  return;
202
203
  }
@@ -66,10 +66,11 @@ export function registerQuickActionRoutes(ctx, deps) {
66
66
  json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
67
67
  return;
68
68
  }
69
- const currentCursor = targetPool.state.cursor ?? 0;
70
- const nextCursor = (currentCursor + 1) % total;
71
- targetPool.state.cursor = nextCursor;
72
- const nextRef = targetPool.refs[nextCursor];
69
+ const currentPointer = targetPool.state.pointer ?? targetPool.state.cursor ?? 0;
70
+ const nextPointer = (currentPointer + 1) % total;
71
+ targetPool.state.pointer = nextPointer;
72
+ targetPool.state.cursor = nextPointer;
73
+ const nextRef = targetPool.refs[nextPointer];
73
74
  targetPool.state.lastUsed = nextRef;
74
75
 
75
76
  if (typeof pushEvent === 'function') {
@@ -80,7 +81,7 @@ export function registerQuickActionRoutes(ctx, deps) {
80
81
  ok: true,
81
82
  action: 'skip',
82
83
  provider,
83
- cursor: nextCursor,
84
+ cursor: nextPointer,
84
85
  activeRef: nextRef,
85
86
  activeTail: keyTail(nextRef),
86
87
  });
@@ -30,20 +30,40 @@ import { getPromptCacheSummary } from './prompt-cache.js';
30
30
  */
31
31
 
32
32
  const DIAG_BUFFER_LIMIT = 200;
33
- const diagBuffer = [];
34
33
 
35
- export function pushDiag(entry) {
36
- if (diagBuffer.length >= DIAG_BUFFER_LIMIT) {
37
- diagBuffer.shift();
34
+ export class DiagBuffer {
35
+ constructor(limit = DIAG_BUFFER_LIMIT) {
36
+ this.limit = limit;
37
+ this.buffer = [];
38
38
  }
39
- diagBuffer.push({
40
- timestamp: Date.now(),
41
- ...entry,
42
- });
39
+
40
+ push(entry) {
41
+ if (this.buffer.length >= this.limit) {
42
+ this.buffer.shift();
43
+ }
44
+ this.buffer.push({
45
+ timestamp: Date.now(),
46
+ ...entry,
47
+ });
48
+ }
49
+
50
+ get() {
51
+ return this.buffer.slice();
52
+ }
53
+
54
+ clear() {
55
+ this.buffer.length = 0;
56
+ }
57
+ }
58
+
59
+ const defaultDiagBuffer = new DiagBuffer();
60
+
61
+ export function pushDiag(entry, buffer = null) {
62
+ (buffer || defaultDiagBuffer).push(entry);
43
63
  }
44
64
 
45
- export function getDiagBuffer() {
46
- return diagBuffer.slice();
65
+ export function getDiagBuffer(buffer = null) {
66
+ return (buffer || defaultDiagBuffer).get();
47
67
  }
48
68
 
49
69
  export function registerTelemetryRoutes(ctx, deps) {
@@ -154,14 +174,15 @@ ctx.effect(() => ctx.webServer.register({
154
174
  json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
155
175
  return;
156
176
  }
177
+ const traceStore = deps.selectionTraceStore || null;
157
178
  if (req.method === 'DELETE') {
158
- clearSelectionTraces();
179
+ clearSelectionTraces(traceStore);
159
180
  json(res, 200, { ok: true, message: 'trace history cleared' });
160
181
  return;
161
182
  }
162
183
  const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
163
184
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
164
- json(res, 200, { ok: true, traces: getSelectionTraces(limit) });
185
+ json(res, 200, { ok: true, traces: getSelectionTraces(limit, traceStore) });
165
186
  },
166
187
  }), 'dsh-key-rotation: trace route');
167
188
 
@@ -185,11 +206,25 @@ ctx.effect(() => ctx.webServer.register({
185
206
  }
186
207
 
187
208
  const runtime = buildRuntime();
188
- const provider = body?.provider || (runtime.pools?.[0]?.base ?? '');
189
- const pool = runtime.providerToPool?.[provider] || runtime.pools?.[0];
209
+ const provider = body?.provider?.trim();
210
+ if (!provider) {
211
+ json(res, 400, { error: { code: 'bad-request', message: 'provider is required for simulation' } });
212
+ return;
213
+ }
214
+
215
+ let pool = null;
216
+ if (runtime.providerToPool instanceof Map) {
217
+ pool = runtime.providerToPool.get(provider);
218
+ } else if (runtime.providerToPool && typeof runtime.providerToPool === 'object') {
219
+ pool = runtime.providerToPool[provider];
220
+ }
221
+ if (!pool) {
222
+ const pools = runtime.pools ?? [];
223
+ pool = pools.find(p => p.provider === provider || p.base === provider);
224
+ }
190
225
 
191
226
  if (!pool) {
192
- json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found` } });
227
+ json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found in configured pools` } });
193
228
  return;
194
229
  }
195
230
 
@@ -234,7 +269,7 @@ ctx.effect(() => ctx.webServer.register({
234
269
  json(res, 200, { ok: true, count: diagBuffer.length });
235
270
  return;
236
271
  }
237
- json(res, 200, { diag: getDiagBuffer() });
272
+ json(res, 200, { diag: deps.diagBuffer ? deps.diagBuffer.get() : getDiagBuffer() });
238
273
  },
239
274
  }), 'dsh-key-rotation: diag route');
240
275
 
@@ -39,6 +39,8 @@ export function buildPoolItem({
39
39
  useKeyRef = null,
40
40
  trafficSmoothingBurst = null,
41
41
  maxQueueWaitMs = null,
42
+ maxAttempts = null,
43
+ maxDurationMs = null,
42
44
  }) {
43
45
  const seen = new Set();
44
46
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -89,7 +91,7 @@ export function buildPoolItem({
89
91
 
90
92
  const tiersMap = {};
91
93
  for (let i = 0; i < refs.length; i++) {
92
- const val = Array.isArray(tiers) ? tiers[indexed[i].index] : (tiers && typeof tiers === object ? tiers[refs[i]] : undefined);
94
+ const val = Array.isArray(tiers) ? tiers[indexed[i].index] : (tiers && typeof tiers === 'object' ? tiers[refs[i]] : undefined);
93
95
  tiersMap[refs[i]] = Number.isFinite(val) && val >= 1 ? Math.floor(val) : 1;
94
96
  }
95
97
  const weightsMap = {};
@@ -143,6 +145,12 @@ const weightsMap = {};
143
145
  maxQueueWaitMs: typeof maxQueueWaitMs === 'number' && Number.isFinite(maxQueueWaitMs) && maxQueueWaitMs >= 0
144
146
  ? Math.floor(maxQueueWaitMs)
145
147
  : 800,
148
+ maxAttempts: typeof maxAttempts === 'number' && Number.isFinite(maxAttempts) && maxAttempts > 0
149
+ ? Math.floor(maxAttempts)
150
+ : 0,
151
+ maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
152
+ ? Math.floor(maxDurationMs)
153
+ : 0,
146
154
  };
147
155
  }
148
156
 
@@ -178,13 +186,18 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
178
186
  const provRpm = typeof p.rpmLimit === 'number' ? p.rpmLimit : rpmLimit;
179
187
  const provTpm = typeof p.tpmLimit === 'number' ? p.tpmLimit : tpmLimit;
180
188
  const provConcurrency = typeof p.concurrencyLimit === 'number' ? p.concurrencyLimit : concurrencyLimit;
189
+ const poolAttempts = typeof p.maxAttempts === 'number' ? p.maxAttempts : (cfg.maxAttempts ?? 0);
190
+ const poolDuration = typeof p.maxDurationMs === 'number' ? p.maxDurationMs : (cfg.maxDurationMs ?? 0);
181
191
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
182
192
  provider: p.provider,
183
193
  model: null,
184
194
  labels: p.labels,
195
+ tiers: p.tiers,
185
196
  pinnedRef: p.pinnedRef ?? p.useKeyRef,
186
197
  trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
187
198
  maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
199
+ maxAttempts: poolAttempts,
200
+ maxDurationMs: poolDuration,
188
201
  }, provRpm, provTpm, provConcurrency);
189
202
  if (pool) {
190
203
  for (const ref of pool.refs) {
@@ -215,6 +228,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
215
228
  pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
216
229
  trafficSmoothingBurst: mp.trafficSmoothingBurst ?? p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
217
230
  maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
231
+ maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
232
+ maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
218
233
  }, modelRpm, modelTpm, modelConcurrency);
219
234
  if (!mpool) continue;
220
235
  mpool.basePool = pool;
package/lib/pool.js CHANGED
@@ -468,14 +468,20 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
468
468
  // and DEFAULT_SWITCH_CODES is exported as an array); normalize before .has().
469
469
  if (!(switchCodes instanceof Set)) switchCodes = new Set(Array.isArray(switchCodes) ? switchCodes : DEFAULT_SWITCH_CODES);
470
470
  const failure = failureOrPayload.failure ?? failureOrPayload;
471
- const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
472
- let code = String(failure.code ?? failure.reason ?? '').toUpperCase();
471
+ let status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
472
+ let rawCode = String(failure.code ?? failure.reason ?? '').trim();
473
+ let code = rawCode.toUpperCase();
473
474
  if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
474
475
  const message = String(failure.message ?? failureOrPayload.message ?? '');
475
476
 
476
- // 1. Direct HTTP status codes (#267: also 408/425 transient)
477
+ // Treat numeric failure.code (e.g. code: "429") as HTTP status if status was not set (#545)
478
+ if (!status && /^\d{3}$/.test(rawCode)) {
479
+ status = Number(rawCode);
480
+ }
481
+
482
+ // 1. Direct HTTP status codes (#267: also 408/425 transient, #545: numeric status/code mapped to semantic switch codes)
477
483
  if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
478
- if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
484
+ if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403') || switchCodes.has(String(status)))) return true;
479
485
  if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
480
486
 
481
487
  // 2. Standard gRPC / cloud error codes
@@ -22,13 +22,14 @@ export function createRequestBudget(options = {}) {
22
22
 
23
23
  const controller = new AbortController();
24
24
  let timerId = null;
25
+ let onCallerAbort = null;
25
26
 
26
27
  // Chain caller signal if provided
27
28
  if (options.signal) {
28
29
  if (options.signal.aborted) {
29
30
  controller.abort(options.signal.reason);
30
31
  } else {
31
- const onCallerAbort = () => {
32
+ onCallerAbort = () => {
32
33
  controller.abort(options.signal.reason);
33
34
  cleanup();
34
35
  };
@@ -52,6 +53,12 @@ export function createRequestBudget(options = {}) {
52
53
  clearTimeout(timerId);
53
54
  timerId = null;
54
55
  }
56
+ if (options.signal && onCallerAbort) {
57
+ try {
58
+ options.signal.removeEventListener('abort', onCallerAbort);
59
+ } catch {}
60
+ onCallerAbort = null;
61
+ }
55
62
  }
56
63
 
57
64
  return {
package/lib/resolver.js CHANGED
@@ -121,7 +121,7 @@ export function createResolver({
121
121
  const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
122
122
  const sessionId = resolveOpts?.sessionId ?? resolveOpts?.session ?? resolveOpts?.metadata?.sessionId ?? currentSession?.();
123
123
  let list = pool.weightedRefs ?? pool.refs;
124
- if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky') {
124
+ if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky' || strategy === 'tier-priority') {
125
125
  list = sortAttemptList(list, strategy, {
126
126
  latencyHistogram,
127
127
  concurrencyTracker,