@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.
package/lib/safe-fetch.js CHANGED
@@ -2,7 +2,6 @@
2
2
  import net from 'node:net';
3
3
  import dns from 'node:dns/promises';
4
4
  import nodeDns from 'node:dns';
5
- import { Agent } from 'undici';
6
5
 
7
6
  export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
8
7
  export const MAX_REDIRECTS = 5;
@@ -105,33 +104,36 @@ export function isPrivateOrReservedIp(ip) {
105
104
  * @returns {Agent}
106
105
  */
107
106
  export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
108
- return new Agent({
109
- connect: {
110
- lookup: (hostname, options, callback) => {
111
- const cb = typeof options === 'function' ? options : callback;
112
- const opts = typeof options === 'function' ? {} : (typeof options === 'number' ? { family: options } : (options || {}));
113
- lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
114
- if (err) return cb(err);
115
- const list = Array.isArray(addresses) ? addresses : [addresses];
116
- for (const item of list) {
117
- const addr = typeof item === 'string' ? item : item.address;
118
- if (isPrivateOrReservedIp(addr)) {
119
- const ssrfErr = new Error(`dsh-key-rotation: connect blocked: host ${hostname} resolves to private or reserved IP ${addr}`);
120
- ssrfErr.code = 'SSRF_BLOCKED';
121
- return cb(ssrfErr);
122
- }
123
- }
124
- if (!opts.all) {
125
- const first = list[0];
126
- const addr = typeof first === 'string' ? first : first?.address;
127
- const fam = typeof first === 'object' && first?.family ? first.family : (net.isIP(addr) || 4);
128
- return cb(null, addr, fam);
129
- }
130
- return cb(null, addresses);
131
- });
132
- },
107
+ const lookup = (hostname, options, callback) => {
108
+ const cb = typeof options === 'function' ? options : callback;
109
+ const opts = typeof options === 'function' ? {} : (typeof options === 'number' ? { family: options } : (options || {}));
110
+ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
111
+ if (err) return cb(err);
112
+ const list = Array.isArray(addresses) ? addresses : [addresses];
113
+ for (const item of list) {
114
+ const addr = typeof item === 'string' ? item : item.address;
115
+ if (isPrivateOrReservedIp(addr)) {
116
+ const ssrfErr = new Error(`dsh-key-rotation: connect blocked: host ${hostname} resolves to private or reserved IP ${addr}`);
117
+ ssrfErr.code = 'SSRF_BLOCKED';
118
+ return cb(ssrfErr);
119
+ }
120
+ }
121
+ if (!opts.all) {
122
+ const first = list[0];
123
+ const addr = typeof first === 'string' ? first : first?.address;
124
+ const fam = typeof first === 'object' && first?.family ? first.family : (net.isIP(addr) || 4);
125
+ return cb(null, addr, fam);
126
+ }
127
+ return cb(null, addresses);
128
+ });
129
+ };
130
+
131
+ return {
132
+ options: {
133
+ connect: { lookup },
133
134
  },
134
- });
135
+ connect: { lookup },
136
+ };
135
137
  }
136
138
 
137
139
  /**
@@ -270,7 +272,7 @@ export async function safeFetchJson(url, {
270
272
  redirectsLeft = MAX_REDIRECTS,
271
273
  dispatcher,
272
274
  } = {}) {
273
- const safeDispatcher = dispatcher ?? (fetchImpl === globalThis.fetch ? createSafeDispatcher() : undefined);
275
+ const safeDispatcher = dispatcher;
274
276
  let currentUrl = url;
275
277
  while (true) {
276
278
  await assertSafeUrl(currentUrl, { lookupImpl });
@@ -1,10 +1,33 @@
1
- // lib/selection-trace.js — Request-scoped key selection explanation and attempt history (#461).
1
+ // lib/selection-trace.js — Request-scoped key selection explanation and attempt history (#461, #544).
2
2
  // Provides auditable reasoning for key skips, selection, failovers, and cascade with strict secret redaction.
3
3
 
4
4
  import { keyTail } from './pool.js';
5
5
 
6
6
  const TRACE_BUFFER_LIMIT = 100;
7
- const traces = [];
7
+
8
+ export class SelectionTraceStore {
9
+ constructor(limit = TRACE_BUFFER_LIMIT) {
10
+ this.limit = limit;
11
+ this.traces = [];
12
+ }
13
+
14
+ push(summary) {
15
+ if (this.traces.length >= this.limit) {
16
+ this.traces.shift();
17
+ }
18
+ this.traces.push(summary);
19
+ }
20
+
21
+ get(limit = 50) {
22
+ return this.traces.slice(-Math.max(1, Math.min(this.limit, limit)));
23
+ }
24
+
25
+ clear() {
26
+ this.traces.length = 0;
27
+ }
28
+ }
29
+
30
+ const defaultSelectionTraceStore = new SelectionTraceStore();
8
31
 
9
32
  /**
10
33
  * Redacts any sensitive tokens from string / object representation (#537).
@@ -30,10 +53,11 @@ export function sanitizeTraceDetails(str) {
30
53
  .slice(0, 300);
31
54
  }
32
55
 
33
- export function createSelectionTrace(requestId, options = {}) {
56
+ export function createSelectionTrace(requestId, options = {}, store = null) {
34
57
  const reqId = requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
35
58
  const startTime = Date.now();
36
59
  const events = [];
60
+ const targetStore = store || defaultSelectionTraceStore;
37
61
 
38
62
  return {
39
63
  requestId: reqId,
@@ -73,19 +97,16 @@ export function createSelectionTrace(requestId, options = {}) {
73
97
  steps: events,
74
98
  };
75
99
 
76
- if (traces.length >= TRACE_BUFFER_LIMIT) {
77
- traces.shift();
78
- }
79
- traces.push(summary);
100
+ targetStore.push(summary);
80
101
  return summary;
81
102
  }
82
103
  };
83
104
  }
84
105
 
85
- export function getSelectionTraces(limit = 50) {
86
- return traces.slice(-Math.max(1, Math.min(100, limit)));
106
+ export function getSelectionTraces(limit = 50, store = null) {
107
+ return (store || defaultSelectionTraceStore).get(limit);
87
108
  }
88
109
 
89
- export function clearSelectionTraces() {
90
- traces.length = 0;
110
+ export function clearSelectionTraces(store = null) {
111
+ (store || defaultSelectionTraceStore).clear();
91
112
  }
package/lib/simulator.js CHANGED
@@ -37,18 +37,24 @@ export function simulateRouting(params = {}) {
37
37
  throw new Error('simulateRouting: valid pool with refs is required');
38
38
  }
39
39
 
40
- // Clone pool state into isolated mock state so real runtime is never touched
40
+ // Clone pool state into isolated mock state so real runtime is never touched (#534)
41
41
  const mockFailedUntil = new Map(pool.state?.failedUntil || []);
42
42
  const mockFailCounts = new Map(pool.state?.failCounts || []);
43
43
  const mockRevokedRefs = new Set(pool.state?.revokedRefs || []);
44
+ const mockBrokenUntil = new Map(pool.state?.brokenUntil || []);
45
+ const mockTokenUsage = pool.state?.tokenUsage ? new Map(pool.state.tokenUsage) : new Map();
46
+ const mockQuotaWindows = pool.state?.quotaWindows ? new Map(pool.state.quotaWindows) : new Map();
44
47
 
45
48
  const simPool = {
46
49
  ...pool,
47
50
  state: {
51
+ ...pool.state,
48
52
  failedUntil: mockFailedUntil,
49
53
  failCounts: mockFailCounts,
50
54
  revokedRefs: mockRevokedRefs,
51
- brokenUntil: new Map(),
55
+ brokenUntil: mockBrokenUntil,
56
+ tokenUsage: mockTokenUsage,
57
+ quotaWindows: mockQuotaWindows,
52
58
  },
53
59
  };
54
60
 
@@ -1,21 +1,26 @@
1
- // lib/traffic-smoother.js — leaky bucket micro-queue traffic smoother (#496).
1
+ // lib/traffic-smoother.js — GCRA leaky bucket micro-queue traffic smoother (#496, #538).
2
2
  // Absorbs momentary request bursts without tripping upstream per-second/per-minute rate limits.
3
+ // Uses Generic Cell Rate Algorithm to ensure atomic slot reservation and burst compliance.
3
4
 
4
5
  export class TrafficSmoother {
5
6
  constructor({ maxQueueWaitMs = 800, now = Date.now, sleep = null } = {}) {
6
7
  this.maxQueueWaitMs = maxQueueWaitMs;
7
8
  this.now = now;
8
9
  this.sleep = sleep || ((ms, signal) => new Promise((resolve, reject) => {
9
- if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
10
+ if (signal?.aborted) {
11
+ const err = (signal.reason instanceof Error) ? signal.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
12
+ return reject(err);
13
+ }
10
14
  const timer = setTimeout(resolve, ms);
11
15
  if (signal) {
12
16
  signal.addEventListener('abort', () => {
13
17
  clearTimeout(timer);
14
- reject(signal.reason ?? new Error('aborted'));
18
+ const err = (signal.reason instanceof Error) ? signal.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
19
+ reject(err);
15
20
  }, { once: true });
16
21
  }
17
22
  }));
18
- // Map<ref, { lastTime: number, tokens: number, lastUpdate: number }>
23
+ // Map<ref, { virtualTime: number }>
19
24
  this._buckets = new Map();
20
25
  }
21
26
 
@@ -45,35 +50,41 @@ export class TrafficSmoother {
45
50
 
46
51
  let bucket = this._buckets.get(ref);
47
52
  if (!bucket) {
48
- bucket = { tokens: capacity, lastUpdate: curNow, nextSlot: curNow };
53
+ bucket = { virtualTime: curNow - capacity * intervalMs };
49
54
  this._buckets.set(ref, bucket);
50
55
  }
51
56
 
52
- // Replenish tokens based on elapsed time
53
- const elapsed = Math.max(0, curNow - bucket.lastUpdate);
54
- const addedTokens = elapsed * (ratePerSec / 1000);
55
- bucket.tokens = Math.min(capacity, bucket.tokens + addedTokens);
56
- bucket.lastUpdate = curNow;
57
+ // GCRA: virtualTime cannot fall behind (curNow - capacity * intervalMs)
58
+ const minVirtualTime = curNow - capacity * intervalMs;
59
+ const vTime = Math.max(minVirtualTime, bucket.virtualTime);
60
+ const targetSlot = vTime + intervalMs;
57
61
 
58
- if (bucket.tokens >= 1) {
59
- bucket.tokens -= 1;
60
- bucket.nextSlot = Math.max(curNow, bucket.nextSlot);
62
+ if (targetSlot <= curNow) {
63
+ // Immediate acquisition within available burst capacity
64
+ bucket.virtualTime = targetSlot;
61
65
  return { allowed: true, delayed: false, delayMs: 0, overflow: false };
62
66
  }
63
67
 
64
- // Burst is exhausted: calculate delay required for next available slot
65
- const targetSlot = Math.max(curNow, bucket.nextSlot) + intervalMs;
66
- const delayMs = Math.max(0, targetSlot - curNow);
68
+ // Queue delay required
69
+ const delayMs = targetSlot - curNow;
67
70
 
68
71
  if (delayMs > maxWait) {
69
72
  // Exceeds max micro-queue wait: signal overflow so caller rotates to next candidate
70
73
  return { allowed: false, delayed: false, delayMs, overflow: true };
71
74
  }
72
75
 
73
- bucket.nextSlot = targetSlot;
76
+ // Atomically reserve the slot ahead of sleeping (#538)
77
+ bucket.virtualTime = targetSlot;
74
78
 
75
- // Wait for the scheduled slot
76
- await this.sleep(delayMs, signal);
79
+ try {
80
+ await this.sleep(delayMs, signal);
81
+ } catch (err) {
82
+ // Rollback reservation if this was the last queued request
83
+ if (bucket.virtualTime === targetSlot) {
84
+ bucket.virtualTime = Math.max(minVirtualTime, targetSlot - intervalMs);
85
+ }
86
+ throw err;
87
+ }
77
88
 
78
89
  return { allowed: true, delayed: true, delayMs, overflow: false };
79
90
  }
@@ -83,7 +94,7 @@ export class TrafficSmoother {
83
94
  const b = this._buckets.get(ref);
84
95
  if (!b) return 0;
85
96
  const curNow = this.now();
86
- const wait = Math.max(0, b.nextSlot - curNow);
97
+ const wait = Math.max(0, b.virtualTime - curNow);
87
98
  return wait > 0 ? Math.ceil(wait / 100) : 0;
88
99
  }
89
100
 
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.73",
4
- "packageManager": "pnpm@10.33.2",
3
+ "version": "0.8.75",
5
4
  "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
5
  "keywords": [
7
6
  "deepseek-harness",
@@ -60,13 +59,6 @@
60
59
  "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0",
61
60
  "@deepseek-ai/schemastery": "^3.18.4"
62
61
  },
63
- "scripts": {
64
- "test": "node tools/test.mjs",
65
- "test:core": "node tools/test.mjs --core",
66
- "check": "node tools/check.mjs",
67
- "build:client": "node scripts/build-client.mjs",
68
- "pretest": "node scripts/build-client.mjs"
69
- },
70
62
  "devDependencies": {
71
63
  "react": "18.3.1",
72
64
  "react-test-renderer": "18.3.1",
@@ -75,7 +67,12 @@
75
67
  "@deepseek-ai/schemastery": "^3.18.4",
76
68
  "semver": "^7.6.3"
77
69
  },
78
- "dependencies": {
79
- "undici": "^6.29.0"
70
+ "dependencies": {},
71
+ "scripts": {
72
+ "test": "node tools/test.mjs",
73
+ "test:core": "node tools/test.mjs --core",
74
+ "check": "node tools/check.mjs",
75
+ "build:client": "node scripts/build-client.mjs",
76
+ "pretest": "node scripts/build-client.mjs"
80
77
  }
81
- }
78
+ }