@goodandready/dsh-key-rotation 0.8.65 → 0.8.67

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,325 @@
1
+ // lib/ops-config-io.js — full configuration export and import (#504).
2
+ import crypto from 'node:crypto';
3
+ import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
4
+ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
5
+ import { keyTail } from './pool.js';
6
+ import { plainConfig } from './index.js';
7
+
8
+ export function encryptConfig(plainText, passphrase) {
9
+ const salt = crypto.randomBytes(16);
10
+ const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
11
+ const iv = crypto.randomBytes(12);
12
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
13
+ const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
14
+ const tag = cipher.getAuthTag();
15
+ return {
16
+ encrypted: true,
17
+ algorithm: 'aes-256-gcm',
18
+ kdf: 'pbkdf2',
19
+ iterations: 100000,
20
+ salt: salt.toString('hex'),
21
+ iv: iv.toString('hex'),
22
+ tag: tag.toString('hex'),
23
+ ciphertext: encrypted.toString('hex'),
24
+ };
25
+ }
26
+
27
+ export function decryptConfig(payload, passphrase) {
28
+ if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
29
+ throw new Error('Invalid encrypted config payload structure');
30
+ }
31
+ const salt = Buffer.from(payload.salt, 'hex');
32
+ const iv = Buffer.from(payload.iv, 'hex');
33
+ const tag = Buffer.from(payload.tag, 'hex');
34
+ const ciphertext = Buffer.from(payload.ciphertext, 'hex');
35
+ const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
36
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
37
+ decipher.setAuthTag(tag);
38
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
39
+ return decrypted.toString('utf8');
40
+ }
41
+
42
+ export function maskConfig(config) {
43
+ if (!config || typeof config !== 'object') return config;
44
+ const clone = JSON.parse(JSON.stringify(config));
45
+ if (Array.isArray(clone.providers)) {
46
+ for (const prov of clone.providers) {
47
+ if (Array.isArray(prov.keys)) {
48
+ prov.keys = prov.keys.map((k) => {
49
+ if (typeof k === 'string') return k.length > 8 ? `sk-...${k.slice(-4)}` : '***';
50
+ if (k && typeof k === 'object') {
51
+ return {
52
+ ...k,
53
+ value: typeof k.value === 'string' ? (k.value.length > 8 ? `sk-...${k.value.slice(-4)}` : '***') : '***',
54
+ };
55
+ }
56
+ return '***';
57
+ });
58
+ }
59
+ }
60
+ }
61
+ if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
62
+ try {
63
+ const u = new URL(clone.webhookUrl);
64
+ const parts = u.pathname.split('/');
65
+ if (parts.length > 2) {
66
+ parts[parts.length - 1] = '***';
67
+ u.pathname = parts.join('/');
68
+ }
69
+ clone.webhookUrl = u.toString();
70
+ } catch (_) {
71
+ clone.webhookUrl = '***';
72
+ }
73
+ }
74
+ if (clone.webhookActionToken) clone.webhookActionToken = '***';
75
+ return clone;
76
+ }
77
+
78
+ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
79
+ const currentProviders = Array.isArray(currentConfig?.providers) ? currentConfig.providers : [];
80
+ const incomingProviders = Array.isArray(incomingConfig?.providers) ? incomingConfig.providers : [];
81
+
82
+ const currentMap = new Map();
83
+ for (const p of currentProviders) {
84
+ const name = p.provider || p.name || p.id;
85
+ if (name) currentMap.set(name, p);
86
+ }
87
+
88
+ const createdProviders = [];
89
+ const updatedProviders = [];
90
+ let keysAdded = 0;
91
+ let keysUpdated = 0;
92
+
93
+ for (const p of incomingProviders) {
94
+ const name = p.provider || p.name || p.id;
95
+ if (!name) continue;
96
+ const inKeys = Array.isArray(p.keys) ? p.keys : [];
97
+ if (!currentMap.has(name)) {
98
+ createdProviders.push(name);
99
+ keysAdded += inKeys.length;
100
+ } else {
101
+ updatedProviders.push(name);
102
+ const curP = currentMap.get(name);
103
+ const curKeys = new Set(Array.isArray(curP.keys) ? curP.keys.map((k) => (typeof k === 'string' ? k : k?.value)) : []);
104
+ for (const k of inKeys) {
105
+ const val = typeof k === 'string' ? k : k?.value;
106
+ if (!curKeys.has(val)) keysAdded++;
107
+ else keysUpdated++;
108
+ }
109
+ }
110
+ }
111
+
112
+ const removedProviders = [];
113
+ if (mode === 'replace') {
114
+ const incomingNames = new Set(incomingProviders.map((p) => p.provider || p.name || p.id).filter(Boolean));
115
+ for (const [name] of currentMap.entries()) {
116
+ if (!incomingNames.has(name)) removedProviders.push(name);
117
+ }
118
+ }
119
+
120
+ const totalKeys = incomingProviders.reduce((acc, p) => acc + (Array.isArray(p.keys) ? p.keys.length : 0), 0);
121
+
122
+ return {
123
+ mode,
124
+ createdProviders,
125
+ updatedProviders,
126
+ removedProviders,
127
+ keysAdded,
128
+ keysUpdated,
129
+ totalKeys,
130
+ };
131
+ }
132
+
133
+ export function registerConfigIoRoutes(ctx, deps) {
134
+ // ── GET /dsh-key-rotation/export ──
135
+ ctx.effect(() => ctx.webServer.register({
136
+ kind: 'exact',
137
+ path: CONFIG_EXPORT_PATH,
138
+ handler: async (req, res) => {
139
+ if (req.method !== 'GET') {
140
+ json(res, 405, { error: { code: 'method', message: 'GET only' } });
141
+ return;
142
+ }
143
+ if (!isTrustedBridgeRequest(req)) {
144
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: export is local-only' } });
145
+ return;
146
+ }
147
+
148
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
149
+ const maskedParam = urlObj.searchParams.get('masked');
150
+ const masked = maskedParam === null ? true : maskedParam !== 'false';
151
+ const passphrase = urlObj.searchParams.get('passphrase') || null;
152
+
153
+ const settings = ctx.get('settings');
154
+ let currentConfig = {};
155
+ try {
156
+ if (settings && typeof settings.get === 'function') {
157
+ currentConfig = settings.get(NS) || {};
158
+ } else if (settings && typeof settings.describe === 'function') {
159
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
160
+ currentConfig = desc?.value || {};
161
+ } else {
162
+ currentConfig = deps.buildRuntime().cfg0 || {};
163
+ }
164
+ } catch (_) {
165
+ currentConfig = deps.buildRuntime().cfg0 || {};
166
+ }
167
+
168
+ const rawConfig = plainConfig(currentConfig);
169
+ const exportData = masked ? maskConfig(rawConfig) : rawConfig;
170
+
171
+ if (passphrase) {
172
+ const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
173
+ json(res, 200, {
174
+ ok: true,
175
+ version: '0.8.67',
176
+ exportedAt: new Date().toISOString(),
177
+ masked,
178
+ encrypted: true,
179
+ ...encryptedPayload,
180
+ });
181
+ return;
182
+ }
183
+
184
+ json(res, 200, {
185
+ ok: true,
186
+ version: '0.8.67',
187
+ exportedAt: new Date().toISOString(),
188
+ masked,
189
+ encrypted: false,
190
+ config: exportData,
191
+ });
192
+ },
193
+ }));
194
+
195
+ // ── POST /dsh-key-rotation/config-import ──
196
+ const handleImport = async (req, res) => {
197
+ if (req.method !== 'POST') {
198
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
199
+ return;
200
+ }
201
+ if (!isTrustedBridgeRequest(req)) {
202
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } });
203
+ return;
204
+ }
205
+
206
+ let body;
207
+ try {
208
+ body = await readJson(req);
209
+ } catch (e) {
210
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
211
+ return;
212
+ }
213
+
214
+ let incomingConfig = null;
215
+ const passphrase = body.passphrase || null;
216
+
217
+ if (body.encrypted && body.ciphertext) {
218
+ if (!passphrase) {
219
+ json(res, 400, { error: { code: 'passphrase-required', message: 'Passphrase is required for encrypted configuration' } });
220
+ return;
221
+ }
222
+ try {
223
+ const decryptedStr = decryptConfig(body, passphrase);
224
+ incomingConfig = JSON.parse(decryptedStr);
225
+ } catch (err) {
226
+ json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
227
+ return;
228
+ }
229
+ } else if (body.config) {
230
+ incomingConfig = body.config;
231
+ } else if (body.providers) {
232
+ incomingConfig = body;
233
+ } else {
234
+ json(res, 400, { error: { code: 'invalid-payload', message: 'Missing config or providers in request' } });
235
+ return;
236
+ }
237
+
238
+ if (!incomingConfig || typeof incomingConfig !== 'object') {
239
+ json(res, 400, { error: { code: 'invalid-config', message: 'Imported configuration must be an object' } });
240
+ return;
241
+ }
242
+
243
+ const mode = body.mode === 'replace' ? 'replace' : 'merge';
244
+ const dryRun = Boolean(body.dryRun);
245
+
246
+ const settings = ctx.get('settings');
247
+ let currentConfig = {};
248
+ try {
249
+ if (settings && typeof settings.get === 'function') {
250
+ currentConfig = settings.get(NS) || {};
251
+ } else if (settings && typeof settings.describe === 'function') {
252
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
253
+ currentConfig = desc?.value || {};
254
+ } else {
255
+ currentConfig = deps.buildRuntime().cfg0 || {};
256
+ }
257
+ } catch (_) {
258
+ currentConfig = deps.buildRuntime().cfg0 || {};
259
+ }
260
+ const rawCurrent = plainConfig(currentConfig);
261
+
262
+ const preview = diffConfigs(rawCurrent, incomingConfig, mode);
263
+
264
+ if (dryRun) {
265
+ json(res, 200, {
266
+ ok: true,
267
+ dryRun: true,
268
+ preview,
269
+ });
270
+ return;
271
+ }
272
+
273
+ // Apply configuration
274
+ let mergedConfig;
275
+ if (mode === 'replace') {
276
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
277
+ if (Array.isArray(incomingConfig.providers)) {
278
+ mergedConfig.providers = incomingConfig.providers;
279
+ }
280
+ } else {
281
+ // Merge mode
282
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
283
+ const curProviders = Array.isArray(rawCurrent.providers) ? [...rawCurrent.providers] : [];
284
+ const curMap = new Map();
285
+ for (let i = 0; i < curProviders.length; i++) {
286
+ const name = curProviders[i].provider || curProviders[i].name || curProviders[i].id;
287
+ if (name) curMap.set(name, i);
288
+ }
289
+ const inProviders = Array.isArray(incomingConfig.providers) ? incomingConfig.providers : [];
290
+ for (const p of inProviders) {
291
+ const name = p.provider || p.name || p.id;
292
+ if (!name) continue;
293
+ if (curMap.has(name)) {
294
+ const idx = curMap.get(name);
295
+ curProviders[idx] = { ...curProviders[idx], ...p };
296
+ } else {
297
+ curProviders.push(p);
298
+ }
299
+ }
300
+ mergedConfig.providers = curProviders;
301
+ }
302
+
303
+ if (settings && typeof settings.replace === 'function') {
304
+ try {
305
+ await settings.replace(NS, mergedConfig);
306
+ } catch (err) {
307
+ json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
308
+ return;
309
+ }
310
+ }
311
+
312
+ json(res, 200, {
313
+ ok: true,
314
+ applied: true,
315
+ mode,
316
+ preview,
317
+ });
318
+ };
319
+
320
+ ctx.effect(() => ctx.webServer.register({
321
+ kind: 'exact',
322
+ path: CONFIG_IMPORT_PATH,
323
+ handler: handleImport,
324
+ }));
325
+ }
package/lib/ops-paths.js CHANGED
@@ -18,3 +18,7 @@ export const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
18
18
  export const DISCOVER_PATH = '/dsh-key-rotation/discover';
19
19
  export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
20
20
  export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
21
+ export const BALANCE_PATH = '/dsh-key-rotation/balance';
22
+ export const CONFIG_EXPORT_PATH = '/dsh-key-rotation/export';
23
+ export const CONFIG_IMPORT_PATH = '/dsh-key-rotation/config-import';
24
+ export const QUICK_ACTION_PATH = '/dsh-key-rotation/quick-action';
@@ -0,0 +1,167 @@
1
+ import { json, readJson, isTrustedBridgeRequest } from './http-bridge.js';
2
+ import { QUICK_ACTION_PATH } from './ops-paths.js';
3
+ import { keyTail, isKeyPaused } from './pool.js';
4
+
5
+ /**
6
+ * @param {object} ctx cordis context
7
+ * @param {object} deps live dependencies from apply()
8
+ */
9
+ export function registerQuickActionRoutes(ctx, deps) {
10
+ const { buildRuntime, pushEvent, circuitBreaker } = deps;
11
+
12
+ ctx.effect(() => ctx.webServer.register({
13
+ kind: 'exact',
14
+ path: QUICK_ACTION_PATH,
15
+ handler: async (req, res) => {
16
+ if (req.method !== 'POST') {
17
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
18
+ return;
19
+ }
20
+ if (!isTrustedBridgeRequest(req)) {
21
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: quick-action is local-only' } });
22
+ return;
23
+ }
24
+
25
+ let body;
26
+ try {
27
+ body = await readJson(req);
28
+ } catch (e) {
29
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
30
+ return;
31
+ }
32
+
33
+ const action = typeof body?.action === 'string' ? body.action.trim().toLowerCase() : '';
34
+ const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
35
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : null;
36
+
37
+ if (!action || !['skip', 'reset', 'pause'].includes(action)) {
38
+ json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, or pause' } });
39
+ return;
40
+ }
41
+
42
+ if (!provider) {
43
+ json(res, 400, { error: { code: 'missing-provider', message: 'provider is required' } });
44
+ return;
45
+ }
46
+
47
+ const runtime = buildRuntime();
48
+ let targetPool = null;
49
+
50
+ const pools = runtime.pools ?? Array.from(runtime.poolByRef?.values() || []);
51
+ for (const pool of pools) {
52
+ if (pool.provider === provider || pool.base === provider) {
53
+ targetPool = pool;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (!targetPool) {
59
+ json(res, 404, { error: { code: 'pool-not-found', message: `Pool not found for provider: ${provider}` } });
60
+ return;
61
+ }
62
+
63
+ if (action === 'skip') {
64
+ const total = targetPool.refs?.length || 0;
65
+ if (total === 0) {
66
+ json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
67
+ return;
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];
73
+ targetPool.state.lastUsed = nextRef;
74
+
75
+ if (typeof pushEvent === 'function') {
76
+ pushEvent(targetPool, nextRef, 'QUICK_ACTION_SKIP', 0);
77
+ }
78
+
79
+ json(res, 200, {
80
+ ok: true,
81
+ action: 'skip',
82
+ provider,
83
+ cursor: nextCursor,
84
+ activeRef: nextRef,
85
+ activeTail: keyTail(nextRef),
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (action === 'reset') {
91
+ targetPool.state.failedUntil?.clear();
92
+ targetPool.state.failCounts?.clear();
93
+ targetPool.state.resetReasons?.clear();
94
+ targetPool.state.headerResetTimes?.clear();
95
+ targetPool.state.switches = 0;
96
+ targetPool.state.lastReason = null;
97
+
98
+ const breaker = circuitBreaker ?? runtime.breaker;
99
+ if (breaker) {
100
+ if (typeof breaker.reset === 'function') breaker.reset(provider);
101
+ else if (typeof breaker.onSuccess === 'function') breaker.onSuccess(provider);
102
+ }
103
+
104
+ if (typeof pushEvent === 'function') {
105
+ pushEvent(targetPool, '*', 'QUICK_ACTION_RESET', 0);
106
+ }
107
+
108
+ json(res, 200, {
109
+ ok: true,
110
+ action: 'reset',
111
+ provider,
112
+ healthyKeys: targetPool.refs?.length || 0,
113
+ });
114
+ return;
115
+ }
116
+
117
+ if (action === 'pause') {
118
+ const targetRef = ref || targetPool.state.lastUsed || targetPool.refs?.[0];
119
+ if (!targetRef) {
120
+ json(res, 400, { error: { code: 'no-ref', message: 'No key specified or active in pool' } });
121
+ return;
122
+ }
123
+
124
+ let isPaused = false;
125
+ if (!targetPool.state.pausedRefs) targetPool.state.pausedRefs = new Set();
126
+
127
+ if (targetPool.state.pausedRefs instanceof Set) {
128
+ if (targetPool.state.pausedRefs.has(targetRef)) {
129
+ targetPool.state.pausedRefs.delete(targetRef);
130
+ targetPool.state.pauseReasons?.delete(targetRef);
131
+ isPaused = false;
132
+ } else {
133
+ targetPool.state.pausedRefs.add(targetRef);
134
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
135
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
136
+ isPaused = true;
137
+ }
138
+ } else if (Array.isArray(targetPool.state.pausedRefs)) {
139
+ const idx = targetPool.state.pausedRefs.indexOf(targetRef);
140
+ if (idx >= 0) {
141
+ targetPool.state.pausedRefs.splice(idx, 1);
142
+ targetPool.state.pauseReasons?.delete(targetRef);
143
+ isPaused = false;
144
+ } else {
145
+ targetPool.state.pausedRefs.push(targetRef);
146
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
147
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
148
+ isPaused = true;
149
+ }
150
+ }
151
+
152
+ if (typeof pushEvent === 'function') {
153
+ pushEvent(targetPool, targetRef, isPaused ? 'KEY_PAUSED' : 'KEY_RESUMED', 0);
154
+ }
155
+
156
+ json(res, 200, {
157
+ ok: true,
158
+ action: 'pause',
159
+ provider,
160
+ ref: targetRef,
161
+ paused: isPaused,
162
+ });
163
+ return;
164
+ }
165
+ },
166
+ }));
167
+ }
package/lib/ops-status.js CHANGED
@@ -124,6 +124,9 @@ ctx.effect(() => ctx.webServer.register({
124
124
  revoked: isKeyRevoked(pool, ref),
125
125
  modelCapabilities: pool.state.modelCapabilities?.get?.(ref) ? [...pool.state.modelCapabilities.get(ref)] : null,
126
126
  detectedLimits: pool.state.detectedLimits?.get?.(ref) ?? null,
127
+ balance: pool.state.balances?.get?.(ref) ?? null,
128
+ resetReason: pool.state.resetReasons?.get?.(ref) ?? null,
129
+ headerResetMs: pool.state.headerResetTimes?.get?.(ref) ?? null,
127
130
  modelQuota,
128
131
  modelPools: activeModelPools.map(mp => mp.base),
129
132
  });
@@ -239,6 +242,8 @@ ctx.effect(() => ctx.webServer.register({
239
242
  providerName: pool.provider ?? providerOfBase(pool.base),
240
243
  basePool: basePoolH?.base ?? null,
241
244
  modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
245
+ lastUsed: pool.state.lastUsed ?? null,
246
+ lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
242
247
  };
243
248
  }
244
249
  let circuitOpenAny = false;
@@ -138,6 +138,13 @@ export class StatePersistence {
138
138
  if (pausedRefs.length > 0) outPool.pausedRefs = pausedRefs;
139
139
  if (Object.keys(pauseReasons).length > 0) outPool.pauseReasons = pauseReasons;
140
140
  if (Object.keys(modelCapabilities).length > 0) outPool.modelCapabilities = modelCapabilities;
141
+ const balances = {};
142
+ if (st && st.balances && typeof st.balances.forEach === 'function') {
143
+ st.balances.forEach((bal, ref) => {
144
+ if (bal && typeof bal === 'object') balances[ref] = bal;
145
+ });
146
+ }
147
+ if (Object.keys(balances).length > 0) outPool.balances = balances;
141
148
  if (Object.keys(tokenUsage).length > 0) outPool.tokenUsage = tokenUsage;
142
149
  if (Object.keys(costDays).length > 0) outPool.costDays = costDays;
143
150
  if (Object.keys(costPerKey).length > 0) outPool.costPerKey = costPerKey;
@@ -241,6 +248,12 @@ export class StatePersistence {
241
248
  if (typeof reason === 'string') state.pauseReasons.set(ref, reason);
242
249
  }
243
250
  }
251
+ if (saved.balances && typeof saved.balances === 'object' && !Array.isArray(saved.balances)) {
252
+ if (!state.balances) state.balances = new Map();
253
+ for (const [ref, bal] of Object.entries(saved.balances)) {
254
+ if (bal && typeof bal === 'object') state.balances.set(ref, bal);
255
+ }
256
+ }
244
257
  if (saved.modelCapabilities && typeof saved.modelCapabilities === 'object' && !Array.isArray(saved.modelCapabilities)) {
245
258
  if (!state.modelCapabilities) state.modelCapabilities = new Map();
246
259
  for (const [ref, list] of Object.entries(saved.modelCapabilities)) {
package/lib/pool-state.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const MAP_FIELDS = [
4
4
  'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
5
5
  'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
6
- 'tokenUsage', 'rpmWindows', 'tpmWindows', 'sessionSticky', 'modelCapabilities', 'pauseReasons', 'detectedLimits',
6
+ 'tokenUsage', 'rpmWindows', 'tpmWindows', 'sessionSticky', 'modelCapabilities', 'pauseReasons', 'detectedLimits', 'balances', 'resetReasons', 'headerResetTimes',
7
7
  ];
8
8
 
9
9
  const SET_FIELDS = ['pausedRefs', 'revokedRefs'];
package/lib/rotate.js CHANGED
@@ -197,16 +197,51 @@ export function createRotate(deps) {
197
197
  }
198
198
  }
199
199
 
200
- const penalizeRef = (targetRef, errCode, errMsg) => {
200
+ const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
201
201
  if (!targetRef) return;
202
202
  if (sessionId) clearSessionSticky(pool, sessionId);
203
203
  if (errCode === LOCAL_QUOTA_CODE) return;
204
- const _retry = parseRetryAfter(errMsg);
204
+ let headerResetMs = null;
205
+ if (rawHeaders) {
206
+ const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
207
+ ? rawHeaders
208
+ : extractProviderLimits(rawHeaders);
209
+ headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
210
+ }
211
+ if (!headerResetMs) {
212
+ const pRetry = parseRetryAfter(errMsg);
213
+ if (pRetry) headerResetMs = pRetry;
214
+ }
215
+
205
216
  const _base = pool.cooldownMs ?? cooldownMs;
206
- const _max = pool.maxCooldownMs ?? maxCooldownMs;
207
- const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
217
+ const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
218
+ let _b = 0;
208
219
  const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
209
- const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
220
+
221
+ if (headerResetMs && headerResetMs > 0) {
222
+ const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
223
+ pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
224
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
225
+ pool.state.resetReasons.set(targetRef, 'header');
226
+ if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
227
+ pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
228
+ _b = dynamicCooldownMs;
229
+ pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
230
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
231
+
232
+ const recTimer = setTimeout(() => {
233
+ if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
234
+ pool.state.failedUntil.delete(targetRef);
235
+ pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
236
+ }
237
+ }, dynamicCooldownMs + 50);
238
+ if (typeof recTimer.unref === 'function') recTimer.unref();
239
+ } else {
240
+ const _effBase = _base;
241
+ _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
242
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
243
+ pool.state.resetReasons.set(targetRef, 'backoff');
244
+ }
210
245
 
211
246
  // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
212
247
  // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
@@ -409,7 +444,7 @@ export function createRotate(deps) {
409
444
  break;
410
445
  }
411
446
  const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
412
- penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
447
+ penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(curRef));
413
448
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
414
449
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
415
450
  logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
@@ -434,6 +469,7 @@ export function createRotate(deps) {
434
469
  continue;
435
470
  }
436
471
  if (chunk && chunk.type === 'finish') {
472
+ const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers ?? chunk?.reason?.failure?.headers;
437
473
  const kind = chunk.reason?.kind;
438
474
  const failure = chunk.reason?.failure;
439
475
  const code = failure?.code;
@@ -442,7 +478,7 @@ export function createRotate(deps) {
442
478
  const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
443
479
  const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
444
480
  if (switchable) {
445
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
481
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
446
482
  pool.state.switches = (pool.state.switches ?? 0) + 1;
447
483
  pool.state.lastReason = String(code ?? 'UNKNOWN');
448
484
  pool.state.lastSwitchAt = now();
@@ -464,7 +500,7 @@ export function createRotate(deps) {
464
500
  // content delta we may penalize, but must never replay the request.
465
501
  if (kind === 'error' || kind === 'aborted') {
466
502
  if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
467
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
503
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
468
504
  }
469
505
  yield chunk;
470
506
  return;
@@ -528,7 +564,6 @@ export function createRotate(deps) {
528
564
  // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
529
565
  // We do NOT re-run this (already successful) request — that would double-send.
530
566
  // #503: Extract provider limits and reset headers
531
- const rawHdrs = chunk?.metadata?.headers ?? chunk?.headers;
532
567
  if (rawHdrs && activeRef) {
533
568
  const detected = extractProviderLimits(rawHdrs);
534
569
  if (detected) {
@@ -574,7 +609,7 @@ export function createRotate(deps) {
574
609
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
575
610
  const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
576
611
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
577
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
612
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
578
613
  pool.state.switches = (pool.state.switches ?? 0) + 1;
579
614
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
580
615
  pool.state.lastSwitchAt = now();
@@ -591,7 +626,7 @@ export function createRotate(deps) {
591
626
  continue; // Failover to next key!
592
627
  }
593
628
  if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
594
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
629
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
595
630
  }
596
631
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
597
632
  return;