@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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/balance-inspector.js +176 -0
- package/lib/client.js +344 -19
- package/lib/ops-balance.js +147 -0
- package/lib/ops-config-io.js +325 -0
- package/lib/ops-paths.js +4 -0
- package/lib/ops-quick-action.js +167 -0
- package/lib/ops-status.js +5 -0
- package/lib/persistence.js +13 -0
- package/lib/pool-state.js +1 -1
- package/lib/rotate.js +46 -11
- package/lib/routes-ops.js +8 -2
- package/package.json +1 -1
|
@@ -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;
|
package/lib/persistence.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|