@goodandready/dsh-key-rotation 0.7.39 → 0.8.0
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/README.md +27 -11
- package/README.ru.md +33 -10
- package/README.zh.md +26 -3
- package/lib/atomic-io.js +60 -0
- package/lib/bounded-map.js +65 -0
- package/lib/circuit-breaker.js +93 -0
- package/lib/clock.js +24 -0
- package/lib/error-taxonomy.js +66 -0
- package/lib/http-bridge.js +169 -0
- package/lib/index.js +97 -944
- package/lib/notify-queue.js +75 -0
- package/lib/pool.js +2 -1
- package/lib/rotate.js +261 -0
- package/lib/routes-ops.js +604 -0
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
1
|
+
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
// The PROVIDER IDENTITY NEVER CHANGES: requests always go out with the
|
|
12
|
-
// provider the user selected, only the resolved API key differs. This keeps
|
|
13
|
-
// pi-ai's replay state consistent across multi-call turns and multi-turn
|
|
14
|
-
// sessions (the earlier clone-provider approach broke it with
|
|
15
|
-
// INVALID_REPLAY_STATE).
|
|
16
|
-
//
|
|
17
|
-
// Config is a KEY POOL PER PROVIDER: you list a real provider id and the env
|
|
18
|
-
// names of its API keys (e.g. <PROVIDER>_API_KEY, <PROVIDER>_API_KEY_2, ...).
|
|
19
|
-
// When a key's limit is exhausted, the request retries on the next key in the
|
|
20
|
-
// list; exhausted keys stay in cooldown for cooldownMs. Clone provider routes
|
|
21
|
-
// (named `<base>-2`, `<base>-3`, ...) are no longer used for rotation but
|
|
22
|
-
// remain registered, so selecting them also rotates (their apiKeyEnv ref
|
|
23
|
-
// belongs to the same pool).
|
|
24
|
-
//
|
|
25
|
-
// The Settings section ("Key Rotation") edits the provider key pools as a
|
|
26
|
-
// simple list: pick a provider from the dropdown of every provider registered
|
|
27
|
-
// with ctx.llm (clone routes are hidden from the dropdown), then add/remove key
|
|
28
|
-
// env names. Plus cooldown and switch codes.
|
|
29
|
-
//
|
|
30
|
-
// Config (all optional, sane defaults):
|
|
31
|
-
// switchCodes: string[] failure codes eligible to switch
|
|
32
|
-
// cooldownMs: number key cooldown after a switchable failure
|
|
33
|
-
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// Transparent key pool rotation: credentials.resolve patch + llm/stream
|
|
4
|
+
// interceptor. Provider identity never changes (keeps pi-ai replay state).
|
|
5
|
+
// See README.md and docs/design/DESIGN.md for the full contract.
|
|
6
|
+
// Config (all optional): switchCodes, cooldownMs, providers[{provider,keys}],
|
|
7
|
+
// verboseLogging (default false) gates per-request rotation logs.
|
|
8
|
+
|
|
35
9
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
36
10
|
import Schema from '@deepseek-ai/schemastery';
|
|
37
11
|
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError, formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
@@ -66,6 +40,15 @@ import { usageRows, usageCsv, compactUsage } from './usage-report.js';
|
|
|
66
40
|
|
|
67
41
|
const dispatchStorage = new AsyncLocalStorage();
|
|
68
42
|
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
43
|
+
import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
|
|
44
|
+
import { createRotate } from './rotate.js';
|
|
45
|
+
import { nowMono } from './clock.js';
|
|
46
|
+
import { CircuitBreaker } from './circuit-breaker.js';
|
|
47
|
+
import { NotifyQueue } from './notify-queue.js';
|
|
48
|
+
import { BoundedMap } from './bounded-map.js';
|
|
49
|
+
import { classifyFailure } from './error-taxonomy.js';
|
|
50
|
+
import { safeParseJson } from './atomic-io.js';
|
|
51
|
+
import { registerOpsRoutes } from './routes-ops.js';
|
|
69
52
|
|
|
70
53
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
71
54
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -90,7 +73,11 @@ export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now:
|
|
|
90
73
|
const now = hooks.now();
|
|
91
74
|
if (now - last < throttle) return;
|
|
92
75
|
switchNotifiedAt.set(info.provider, now);
|
|
93
|
-
|
|
76
|
+
// #263: non-blocking — enqueue never awaits webhook I/O
|
|
77
|
+
const send = hooks.notifyQueue
|
|
78
|
+
? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
|
|
79
|
+
: (url, payload) => hooks.webhookSender.send(url, payload);
|
|
80
|
+
send(runtime.notifyWebhook, {
|
|
94
81
|
title: `Key switched: ${info.provider}`,
|
|
95
82
|
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
96
83
|
provider: info.provider,
|
|
@@ -121,11 +108,18 @@ let lastTestCacheRunnerCtx = null;
|
|
|
121
108
|
const lastTestCache = new LastTestCache();
|
|
122
109
|
const latencyHistogram = new LatencyHistogram();
|
|
123
110
|
const quotaStore = new QuotaStore();
|
|
111
|
+
// #260/#263 module-scope infra (process-wide, reset on reload via buildRuntime)
|
|
112
|
+
let moduleBreaker = null;
|
|
113
|
+
let moduleNotifyQueue = null;
|
|
124
114
|
// Global config accessor safe against early initialization
|
|
125
115
|
let getConfig = () => null;
|
|
126
116
|
let getRuntime = () => null;
|
|
127
117
|
let sandboxRunner = null;
|
|
128
118
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
119
|
+
// #263: queue webhook I/O off the hot path
|
|
120
|
+
moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
|
|
121
|
+
// #260: process-wide breaker; thresholds re-read from runtime when dispatching
|
|
122
|
+
moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
|
|
129
123
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
130
124
|
function ensureSandboxRunner(ctx) {
|
|
131
125
|
if (sandboxRunner) return sandboxRunner;
|
|
@@ -204,6 +198,12 @@ export const Config = Schema.object({
|
|
|
204
198
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
205
199
|
expiryWarnDays: Schema.number().default(7),
|
|
206
200
|
switchNotify: Schema.boolean().default(false),
|
|
201
|
+
verboseLogging: Schema.boolean().default(false),
|
|
202
|
+
// #260 circuit breaker
|
|
203
|
+
circuitBreakerEnabled: Schema.boolean().default(true),
|
|
204
|
+
circuitBreakerThreshold: Schema.number().default(5),
|
|
205
|
+
circuitBreakerOpenMs: Schema.number().default(30000),
|
|
206
|
+
circuitBreakerHalfOpenProbes: Schema.number().default(1),
|
|
207
207
|
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
208
208
|
warnBelowHealthy: Schema.number().default(0),
|
|
209
209
|
latencySloMs: Schema.number().default(0),
|
|
@@ -228,163 +228,6 @@ export const Config = Schema.object({
|
|
|
228
228
|
// ── config bridge (GET/PUT/DELETE on CONFIG_PATH), mirroring llm-fallback ──
|
|
229
229
|
|
|
230
230
|
|
|
231
|
-
function json(res, status, obj) {
|
|
232
|
-
res.writeHead(status, { 'content-type': 'application/json' });
|
|
233
|
-
res.end(JSON.stringify(obj));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function readJson(request) {
|
|
237
|
-
return new Promise((resolve, reject) => {
|
|
238
|
-
let raw = '';
|
|
239
|
-
request.on('data', (c) => { raw += c; });
|
|
240
|
-
request.on('end', () => {
|
|
241
|
-
try {
|
|
242
|
-
resolve(JSON.parse(raw || '{}'));
|
|
243
|
-
} catch (e) {
|
|
244
|
-
reject(e);
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
request.on('error', reject);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function descriptorOf(ctx, ns) {
|
|
252
|
-
const settings = ctx.get('settings');
|
|
253
|
-
if (settings === void 0) return void 0;
|
|
254
|
-
return settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function viewOf(descriptor, settings) {
|
|
258
|
-
return {
|
|
259
|
-
available: true,
|
|
260
|
-
writable: settings.writable,
|
|
261
|
-
hasDocument: settings.hasDocument,
|
|
262
|
-
value: descriptor.value,
|
|
263
|
-
...descriptor.base === void 0 ? {} : { base: descriptor.base },
|
|
264
|
-
...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
|
|
265
|
-
revision: descriptor.revision,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function writeSection(ctx, ns, section, expectedRevision, res) {
|
|
270
|
-
const settings = ctx.get('settings');
|
|
271
|
-
if (settings === void 0) {
|
|
272
|
-
json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider is mounted' } });
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
try {
|
|
276
|
-
await settings.replace(ns, section, expectedRevision);
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (error?.code === 'SETTINGS_CONFLICT') {
|
|
279
|
-
json(res, 409, { error: { code: 'settings-conflict', message: `dsh-key-rotation: changed elsewhere (expected revision ${String(error.expected)}, current ${String(error.actual)}); reload and retry` } });
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
json(res, 400, { error: { code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) } });
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
const descriptor = descriptorOf(ctx, ns);
|
|
286
|
-
if (descriptor === void 0) {
|
|
287
|
-
json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Provider catalog for the GUI dropdown, minus clone routes of configured chains. */
|
|
294
|
-
function providerCatalog(ctx, cloneIds) {
|
|
295
|
-
const seen = new Set();
|
|
296
|
-
const out = [];
|
|
297
|
-
for (const info of ctx.llm.listProviders()) {
|
|
298
|
-
if (seen.has(info.id) || cloneIds.has(info.id)) continue;
|
|
299
|
-
seen.add(info.id);
|
|
300
|
-
out.push({ id: info.id, name: info.name ?? info.id });
|
|
301
|
-
}
|
|
302
|
-
return out;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
306
|
-
if (!isTrustedBridgeRequest(request)) {
|
|
307
|
-
res.writeHead(403);
|
|
308
|
-
res.end();
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
const method = request.method ?? 'GET';
|
|
312
|
-
if (method === 'GET') {
|
|
313
|
-
const settings = ctx.get('settings');
|
|
314
|
-
const descriptor = descriptorOf(ctx, NS);
|
|
315
|
-
const body = {
|
|
316
|
-
providers: providerCatalog(ctx, getCloneIds()),
|
|
317
|
-
};
|
|
318
|
-
if (descriptor === void 0) {
|
|
319
|
-
json(res, 200, {
|
|
320
|
-
...body,
|
|
321
|
-
available: false,
|
|
322
|
-
writable: settings?.writable ?? false,
|
|
323
|
-
hasDocument: settings?.documentPath !== void 0,
|
|
324
|
-
value: void 0,
|
|
325
|
-
revision: 0,
|
|
326
|
-
});
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
json(res, 200, {
|
|
330
|
-
...body,
|
|
331
|
-
...viewOf(descriptor, {
|
|
332
|
-
writable: settings?.writable ?? false,
|
|
333
|
-
hasDocument: settings?.documentPath !== void 0,
|
|
334
|
-
}),
|
|
335
|
-
});
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
if (method === 'PUT' || method === 'DELETE') {
|
|
339
|
-
let section;
|
|
340
|
-
let expectedRevision;
|
|
341
|
-
if (method === 'PUT') {
|
|
342
|
-
let body;
|
|
343
|
-
try {
|
|
344
|
-
body = await readJson(request);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
json(res, 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
if (typeof body !== 'object' || body === null || typeof body.section !== 'object' || body.section === null || Array.isArray(body.section)) {
|
|
350
|
-
json(res, 400, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: PUT requires {"section": {...}}' } });
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
section = body.section;
|
|
354
|
-
expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : void 0;
|
|
355
|
-
// #200 leak detector: a live secret pasted into the config section is
|
|
356
|
-
// almost always a mistake (real key values belong in PUT /key). The two
|
|
357
|
-
// fields that legitimately hold tokens are masked before scanning.
|
|
358
|
-
try {
|
|
359
|
-
const masked = structuredClone(section);
|
|
360
|
-
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
361
|
-
// notifyWebhook legitimately carries bot tokens inside URLs
|
|
362
|
-
// (api.telegram.org/bot<token>/...) - scan it for nothing.
|
|
363
|
-
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
364
|
-
const findings = findSecrets(JSON.stringify(masked));
|
|
365
|
-
if (findings.length > 0) {
|
|
366
|
-
json(res, 400, {
|
|
367
|
-
error: {
|
|
368
|
-
code: 'secret-in-config',
|
|
369
|
-
message: `dsh-key-rotation: value looks like a live credential (${findings[0].type}); store key values via the key field, not the config section`,
|
|
370
|
-
findings,
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
} catch {
|
|
376
|
-
/* scanning must never block a valid save */
|
|
377
|
-
}
|
|
378
|
-
} else {
|
|
379
|
-
section = {};
|
|
380
|
-
}
|
|
381
|
-
await writeSection(ctx, NS, section, expectedRevision, res);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
res.writeHead(405);
|
|
385
|
-
res.end();
|
|
386
|
-
}
|
|
387
|
-
|
|
388
231
|
function registerConfigBridge(ctx, getCloneIds) {
|
|
389
232
|
return ctx.webServer.register({
|
|
390
233
|
kind: 'exact',
|
|
@@ -622,6 +465,8 @@ export function apply(ctx, config = {}) {
|
|
|
622
465
|
byModel: new Map(),
|
|
623
466
|
usageDays: new Map(),
|
|
624
467
|
quotaWindows: new Map(),
|
|
468
|
+
// #260 per-provider circuit breaker (lazy)
|
|
469
|
+
breaker: null,
|
|
625
470
|
pointer: 0,
|
|
626
471
|
lastUsed: undefined,
|
|
627
472
|
switches: 0,
|
|
@@ -716,7 +561,30 @@ export function apply(ctx, config = {}) {
|
|
|
716
561
|
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
717
562
|
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
718
563
|
}
|
|
719
|
-
|
|
564
|
+
// #266: orphan clone-route GC — expected clones only for live multi-key providers
|
|
565
|
+
const expectedClones = new Set();
|
|
566
|
+
for (const p of cfg.providers ?? []) {
|
|
567
|
+
const n = (p.keys ?? []).filter((k) => typeof k === 'string' && k.length > 0).length;
|
|
568
|
+
for (let i = 1; i < n; i++) expectedClones.add(`${p.provider}-${i + 1}`);
|
|
569
|
+
}
|
|
570
|
+
// drop breaker entries for removed providers
|
|
571
|
+
if (moduleBreaker) {
|
|
572
|
+
for (const key of Object.keys(moduleBreaker.snapshot())) {
|
|
573
|
+
if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
|
|
574
|
+
// keep until TTL; only reset if provider gone from config entirely
|
|
575
|
+
const still = (cfg.providers ?? []).some((p) => p.provider === key);
|
|
576
|
+
if (!still) moduleBreaker.reset(key);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
|
|
581
|
+
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
582
|
+
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
583
|
+
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
584
|
+
circuitBreakerHalfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
|
|
585
|
+
breaker: moduleBreaker,
|
|
586
|
+
notifyQueue: moduleNotifyQueue,
|
|
587
|
+
};
|
|
720
588
|
lastConfigRef = rawConfig;
|
|
721
589
|
lastProfilesRef = currentProfiles;
|
|
722
590
|
return cachedRuntime;
|
|
@@ -849,757 +717,37 @@ export function apply(ctx, config = {}) {
|
|
|
849
717
|
} catch (_) { /* ponytail: never crash */ }
|
|
850
718
|
}
|
|
851
719
|
|
|
720
|
+
// rotate() factory (#253): dependencies injected for testability
|
|
721
|
+
const rotate = createRotate({
|
|
722
|
+
ctx,
|
|
723
|
+
dispatchStorage,
|
|
724
|
+
buildRuntime,
|
|
725
|
+
pushEvent,
|
|
726
|
+
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
727
|
+
notifyExhaustion,
|
|
728
|
+
recordLatency,
|
|
729
|
+
concurrencyTracker,
|
|
730
|
+
MARKER,
|
|
731
|
+
finishError,
|
|
732
|
+
setRotateStartMs: (v) => { _rotateStartMs = v; },
|
|
733
|
+
quotaStore,
|
|
734
|
+
circuitBreaker: moduleBreaker,
|
|
735
|
+
now: nowMono,
|
|
736
|
+
});
|
|
737
|
+
|
|
852
738
|
// Retry one request on the next pool key when the current key fails with a
|
|
853
739
|
// switchable error before any content chunk. The provider never changes —
|
|
854
740
|
// the resolve patch hands out the next key on each dispatch.
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const available = attemptList.filter((r) => {
|
|
866
|
-
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
867
|
-
if (fu > Date.now()) return false;
|
|
868
|
-
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
869
|
-
if (exp !== undefined && Date.now() >= exp) return false;
|
|
870
|
-
return true;
|
|
871
|
-
});
|
|
872
|
-
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
873
|
-
if (preferred && attemptList[0] !== preferred) {
|
|
874
|
-
const list = attemptList.slice();
|
|
875
|
-
const i = list.indexOf(preferred);
|
|
876
|
-
if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
|
|
877
|
-
attemptList = list;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
882
|
-
if (!targetRef) return;
|
|
883
|
-
const _retry = parseRetryAfter(errMsg);
|
|
884
|
-
const _base = pool.cooldownMs ?? cooldownMs;
|
|
885
|
-
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
886
|
-
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
887
|
-
const _b = recordFailure(pool, targetRef, Date.now(), _effBase, _max);
|
|
888
|
-
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
889
|
-
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
890
|
-
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
891
|
-
const _cStr = String(errCode ?? '');
|
|
892
|
-
if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
|
|
893
|
-
const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
|
|
894
|
-
pool.state.authFailCounts.set(targetRef, _c2);
|
|
895
|
-
if (_c2 >= 3) {
|
|
896
|
-
pool.state.brokenUntil.set(targetRef, Date.now() + 86400000 * 30);
|
|
897
|
-
pool.state.failedUntil.set(targetRef, Date.now() + 86400000 * 30);
|
|
898
|
-
}
|
|
899
|
-
} else {
|
|
900
|
-
pool.state.authFailCounts.delete(targetRef);
|
|
901
|
-
}
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
905
|
-
let yielded = false;
|
|
906
|
-
let switching = false;
|
|
907
|
-
let inner;
|
|
908
|
-
try {
|
|
909
|
-
// mark the internal dispatch so the interceptor does not re-rotate
|
|
910
|
-
inner = dispatchStorage.run(reqStore, () => ctx.llm.stream({ ...options, [MARKER]: true }));
|
|
911
|
-
} catch (e) {
|
|
912
|
-
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
913
|
-
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
914
|
-
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
915
|
-
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
916
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
917
|
-
continue;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
921
|
-
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
|
|
922
|
-
try {
|
|
923
|
-
for await (const chunk of inner) {
|
|
924
|
-
// Only actual content deltas lock the stream (no more rotation).
|
|
925
|
-
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
926
|
-
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
927
|
-
yielded = true;
|
|
928
|
-
yield chunk;
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
931
|
-
if (chunk && chunk.type === 'finish') {
|
|
932
|
-
const kind = chunk.reason?.kind;
|
|
933
|
-
const failure = chunk.reason?.failure;
|
|
934
|
-
const code = failure?.code;
|
|
935
|
-
const message = failure?.message ?? '';
|
|
936
|
-
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
937
|
-
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
938
|
-
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
939
|
-
if (switchable) {
|
|
940
|
-
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
941
|
-
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
942
|
-
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
943
|
-
pool.state.lastSwitchAt = Date.now();
|
|
944
|
-
lastFailure = chunk;
|
|
945
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
946
|
-
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
947
|
-
if (switchNotify && activeRef) {
|
|
948
|
-
notifySwitch(runtime0, pool, {
|
|
949
|
-
provider: options.provider,
|
|
950
|
-
from: activeRef,
|
|
951
|
-
code: String(code ?? 'UNKNOWN'),
|
|
952
|
-
at: pool.state.lastSwitchAt,
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
switching = true;
|
|
956
|
-
break;
|
|
957
|
-
}
|
|
958
|
-
// cost tracking if provider returns usage.cost
|
|
959
|
-
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
960
|
-
if (chunk.usage?.cost != null && activeRef) {
|
|
961
|
-
const c = Number(chunk.usage.cost);
|
|
962
|
-
if (!isNaN(c)) {
|
|
963
|
-
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
964
|
-
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
965
|
-
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
966
|
-
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
967
|
-
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
968
|
-
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
969
|
-
pool.state.costDays.set(activeRef, cMap);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
// Usage by day (#119)
|
|
973
|
-
if (activeRef) {
|
|
974
|
-
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
975
|
-
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
976
|
-
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
977
|
-
pool.state.usageDays.set(activeRef, dayMap);
|
|
978
|
-
}
|
|
979
|
-
// Per-model request detail (#121)
|
|
980
|
-
if (activeRef && options.model) {
|
|
981
|
-
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
982
|
-
let byRef = pool.state.byModel.get(activeRef);
|
|
983
|
-
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
984
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
985
|
-
}
|
|
986
|
-
// Proactive rate-limit (#115): if response headers say this key is near
|
|
987
|
-
// its quota, cool it down so the NEXT request starts on a different key.
|
|
988
|
-
// We do NOT re-run this (already successful) request — that would double-send.
|
|
989
|
-
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
990
|
-
if (rate && activeRef) {
|
|
991
|
-
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
992
|
-
const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
|
|
993
|
-
recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
|
|
994
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
995
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
999
|
-
if (rate && activeRef && Number.isFinite(rate.remaining)) {
|
|
1000
|
-
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
|
|
1001
|
-
}
|
|
1002
|
-
yield chunk;
|
|
1003
|
-
recordLatency(pool, reqStore);
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
yield chunk;
|
|
1007
|
-
}
|
|
1008
|
-
} catch (e) {
|
|
1009
|
-
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
1010
|
-
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1011
|
-
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
1012
|
-
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
1013
|
-
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1014
|
-
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1015
|
-
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
1016
|
-
pool.state.lastSwitchAt = Date.now();
|
|
1017
|
-
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1018
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
1019
|
-
if (switchNotify && activeRef) {
|
|
1020
|
-
notifySwitch(runtime0, pool, {
|
|
1021
|
-
provider: options.provider,
|
|
1022
|
-
from: activeRef,
|
|
1023
|
-
code: String(e?.code ?? 'TRANSPORT'),
|
|
1024
|
-
at: pool.state.lastSwitchAt,
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
|
-
continue; // Failover to next key!
|
|
1028
|
-
}
|
|
1029
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
1034
|
-
if (switching) continue; // try the next key
|
|
1035
|
-
return; // clean end — served
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// pool exhausted — all keys cooling or missing
|
|
1039
|
-
pool.state.lastExhaustionAt = Date.now();
|
|
1040
|
-
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
1041
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
1042
|
-
const runtime = buildRuntime();
|
|
1043
|
-
// notify via extracted helper (see notifyExhaustion above)
|
|
1044
|
-
notifyExhaustion(runtime, pool, { provider: options.provider });
|
|
1045
|
-
|
|
1046
|
-
// #194: cross-provider cascade failover (guarded against infinite recursion)
|
|
1047
|
-
if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
1048
|
-
const pools = runtime.providerToPool;
|
|
1049
|
-
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
1050
|
-
if (fb && fb.pool && fb.pool !== pool) {
|
|
1051
|
-
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
1052
|
-
pool.state.lastReason = 'CASCADE';
|
|
1053
|
-
pool.state.lastSwitchAt = Date.now();
|
|
1054
|
-
// Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
|
|
1055
|
-
const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
|
|
1056
|
-
for await (const chunk of innerCascade) {
|
|
1057
|
-
yield chunk;
|
|
1058
|
-
}
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
|
|
1064
|
-
yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
|
|
1065
|
-
})();
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// ── status route: what the settings card cannot know on its own ──
|
|
1069
|
-
//
|
|
1070
|
-
// Reports, per configured provider, which key is in use, which are cooling
|
|
1071
|
-
// down and until when, whether an env name resolves to a credential at all
|
|
1072
|
-
// (a typo is otherwise silent), and how often rotation has fired.
|
|
1073
|
-
//
|
|
1074
|
-
// Key VALUES never leave the host — only the boolean fact that one exists.
|
|
1075
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1076
|
-
kind: 'exact',
|
|
1077
|
-
path: STATUS_PATH,
|
|
1078
|
-
handler: async (req, res) => {
|
|
1079
|
-
if (req.method !== 'GET') {
|
|
1080
|
-
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
1084
|
-
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
const runtime = buildRuntime();
|
|
1088
|
-
const { poolByRef, providerTags, providerBudgets, latencySloMs } = runtime;
|
|
1089
|
-
const base = ctx.get('credentials');
|
|
1090
|
-
const now = Date.now();
|
|
1091
|
-
const seen = new Set();
|
|
1092
|
-
const providers = [];
|
|
1093
|
-
for (const pool of poolByRef.values()) {
|
|
1094
|
-
if (seen.has(pool.base)) continue;
|
|
1095
|
-
seen.add(pool.base);
|
|
1096
|
-
try {
|
|
1097
|
-
const keys = [];
|
|
1098
|
-
for (const ref of pool.refs) {
|
|
1099
|
-
let present = false;
|
|
1100
|
-
let tail = '';
|
|
1101
|
-
let source = null;
|
|
1102
|
-
let writable = true;
|
|
1103
|
-
try {
|
|
1104
|
-
// The resolve patch is installed on this same service, so ask for
|
|
1105
|
-
// the exact ref: a pool ref would otherwise round-robin to another
|
|
1106
|
-
// key and report a missing name as present.
|
|
1107
|
-
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
1108
|
-
present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
1109
|
-
if (present) tail = keyTail(hit.value);
|
|
1110
|
-
// fallback: env var bootstrapping (issue #7)
|
|
1111
|
-
if (!present) {
|
|
1112
|
-
const ev = envValue(ref);
|
|
1113
|
-
if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
|
|
1114
|
-
}
|
|
1115
|
-
} catch {
|
|
1116
|
-
present = false;
|
|
1117
|
-
}
|
|
1118
|
-
try {
|
|
1119
|
-
const described = await base?.describe?.(ref);
|
|
1120
|
-
source = described?.source ?? null;
|
|
1121
|
-
writable = described?.writable !== false;
|
|
1122
|
-
} catch {
|
|
1123
|
-
/* describe is optional — the card falls back to editable */
|
|
1124
|
-
}
|
|
1125
|
-
const until = pool.state.failedUntil.get(ref);
|
|
1126
|
-
keys.push({
|
|
1127
|
-
ref,
|
|
1128
|
-
present,
|
|
1129
|
-
tail,
|
|
1130
|
-
source,
|
|
1131
|
-
writable,
|
|
1132
|
-
active: pool.state.lastUsed === ref,
|
|
1133
|
-
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
1134
|
-
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
1135
|
-
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
1136
|
-
// #215: effective round-robin weight of this key
|
|
1137
|
-
weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
|
|
1138
|
-
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
1139
|
-
byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
|
|
1140
|
-
usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
|
|
1141
|
-
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
1142
|
-
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
1143
|
-
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
1144
|
-
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
1145
|
-
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
providers.push({
|
|
1149
|
-
provider: pool.base,
|
|
1150
|
-
keys,
|
|
1151
|
-
tags: providerTags.get(pool.base) ?? [],
|
|
1152
|
-
switches: pool.state.switches ?? 0,
|
|
1153
|
-
lastReason: pool.state.lastReason ?? null,
|
|
1154
|
-
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
1155
|
-
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
1156
|
-
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
1157
|
-
totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
|
|
1158
|
-
// #225: aggregate p95 across the pool's keys
|
|
1159
|
-
p95: (() => {
|
|
1160
|
-
const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
|
|
1161
|
-
return vals.length ? Math.round(Math.max(...vals)) : null;
|
|
1162
|
-
})(),
|
|
1163
|
-
latencySloMs,
|
|
1164
|
-
events: (pool.state.events ?? []).slice(-50),
|
|
1165
|
-
healthScore: computeHealthScore(pool.state),
|
|
1166
|
-
// #208: today/week spend + configured budget for the card
|
|
1167
|
-
todayCost: costForDay(pool.state.costDays),
|
|
1168
|
-
weeklyCost: costForWeek(pool.state.costDays, now),
|
|
1169
|
-
budgetDaily: providerBudgets.get(pool.base)?.costBudgetDaily ?? 0,
|
|
1170
|
-
budgetWeekly: providerBudgets.get(pool.base)?.costBudgetWeekly ?? 0,
|
|
1171
|
-
pauseOnBudget: providerBudgets.get(pool.base)?.pauseOnBudget ?? false,
|
|
1172
|
-
});
|
|
1173
|
-
} catch (e) {
|
|
1174
|
-
console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
1175
|
-
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
json(res, 200, { providers });
|
|
1179
|
-
},
|
|
1180
|
-
}), 'dsh-key-rotation: status route');
|
|
1181
|
-
|
|
1182
|
-
// #209: usage report - per-key requests/cost over the last N days.
|
|
1183
|
-
// ?format=csv returns text/csv; ?days=N window (1..90, default 7).
|
|
1184
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1185
|
-
kind: 'exact',
|
|
1186
|
-
path: USAGE_PATH,
|
|
1187
|
-
handler: (req, res) => {
|
|
1188
|
-
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
1189
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
|
|
1190
|
-
const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
|
|
1191
|
-
const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
|
|
1192
|
-
const csv = url.searchParams.get('format') === 'csv';
|
|
1193
|
-
const provider = url.searchParams.get('provider') ?? '';
|
|
1194
|
-
const runtime = buildRuntime();
|
|
1195
|
-
const now = Date.now();
|
|
1196
|
-
const seen = new Set();
|
|
1197
|
-
const report = [];
|
|
1198
|
-
for (const pool of runtime.poolByRef.values()) {
|
|
1199
|
-
if (seen.has(pool.base)) continue;
|
|
1200
|
-
seen.add(pool.base);
|
|
1201
|
-
if (provider && pool.base !== provider) continue;
|
|
1202
|
-
report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
|
|
1203
|
-
}
|
|
1204
|
-
if (csv) {
|
|
1205
|
-
res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
|
|
1206
|
-
const parts = [];
|
|
1207
|
-
for (const p of report) {
|
|
1208
|
-
if (parts.length > 0) parts.push('');
|
|
1209
|
-
parts.push('# ' + p.provider);
|
|
1210
|
-
parts.push(usageCsv(p.rows));
|
|
1211
|
-
}
|
|
1212
|
-
res.end(parts.join('\n') + '\n');
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
json(res, 200, { at: now, days, providers: report });
|
|
1216
|
-
},
|
|
1217
|
-
}), 'dsh-key-rotation: usage route');
|
|
1218
|
-
|
|
1219
|
-
// #218: full config snapshot - one JSON file to move between machines.
|
|
1220
|
-
// Secret values never travel: only credential/env names. Token fields are
|
|
1221
|
-
// exported as empty strings; on import they keep existing values when empty.
|
|
1222
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1223
|
-
kind: 'exact',
|
|
1224
|
-
path: SNAPSHOT_PATH,
|
|
1225
|
-
handler: async (req, res) => {
|
|
1226
|
-
if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
|
|
1227
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
|
|
1228
|
-
if (req.method === 'GET') {
|
|
1229
|
-
const descriptor = descriptorOf(ctx, NS);
|
|
1230
|
-
const value = descriptor?.value ?? {};
|
|
1231
|
-
const exportable = { ...value };
|
|
1232
|
-
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1233
|
-
exportable.webhookActionToken = '';
|
|
1234
|
-
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
// POST = import: { snapshot } -> merge with current section, PUT semantics
|
|
1238
|
-
let body;
|
|
1239
|
-
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1240
|
-
const snap = body?.snapshot;
|
|
1241
|
-
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
|
|
1242
|
-
// #200 leak guard applies to imported content too
|
|
1243
|
-
try {
|
|
1244
|
-
const masked = structuredClone(snap);
|
|
1245
|
-
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1246
|
-
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1247
|
-
const findings = findSecrets(JSON.stringify(masked));
|
|
1248
|
-
if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
|
|
1249
|
-
} catch { /* scanning must never block a valid import */ }
|
|
1250
|
-
const settings = ctx.get('settings');
|
|
1251
|
-
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
1252
|
-
const desc = descriptorOf(ctx, NS);
|
|
1253
|
-
if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
|
|
1254
|
-
const cur = desc.value ?? {};
|
|
1255
|
-
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1256
|
-
const merged = { ...cur, ...snap };
|
|
1257
|
-
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1258
|
-
try {
|
|
1259
|
-
await settings.replace(NS, merged, desc.revision);
|
|
1260
|
-
const after = descriptorOf(ctx, NS);
|
|
1261
|
-
json(res, 200, { ok: true, revision: after?.revision });
|
|
1262
|
-
} catch (e) {
|
|
1263
|
-
json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
|
|
1264
|
-
}
|
|
1265
|
-
},
|
|
1266
|
-
}), 'dsh-key-rotation: snapshot route');
|
|
1267
|
-
|
|
1268
|
-
// ── key route: store a key value without leaving the rotation card ──
|
|
1269
|
-
//
|
|
1270
|
-
// Adding a key used to mean two screens: create the credential elsewhere,
|
|
1271
|
-
// then type its env name here. The value is write-only from the browser —
|
|
1272
|
-
// it is never sent back, only its last few characters are (see the status
|
|
1273
|
-
// route) — and the route is loopback- and same-origin-gated like the config
|
|
1274
|
-
// bridge next to it.
|
|
1275
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1276
|
-
kind: 'exact',
|
|
1277
|
-
path: KEY_PATH,
|
|
1278
|
-
handler: async (req, res) => {
|
|
1279
|
-
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
1280
|
-
json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
1284
|
-
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
const credentialsService = ctx.get('credentials');
|
|
1288
|
-
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
|
1289
|
-
json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
let body;
|
|
1293
|
-
try {
|
|
1294
|
-
body = await readJson(req);
|
|
1295
|
-
} catch (error) {
|
|
1296
|
-
json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
|
|
1297
|
-
return;
|
|
1298
|
-
}
|
|
1299
|
-
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1300
|
-
if (!isValidRef(ref)) {
|
|
1301
|
-
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
1302
|
-
return;
|
|
1303
|
-
}
|
|
1304
|
-
try {
|
|
1305
|
-
if (req.method === 'DELETE') {
|
|
1306
|
-
await credentialsService.unset(ref);
|
|
1307
|
-
json(res, 200, { ok: true, ref });
|
|
1308
|
-
return;
|
|
1309
|
-
}
|
|
1310
|
-
const value = typeof body?.value === 'string' ? body.value.trim() : '';
|
|
1311
|
-
if (value.length === 0) {
|
|
1312
|
-
json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
await credentialsService.set(ref, value);
|
|
1316
|
-
// #200: leak-detector hint - stored value should look like a credential
|
|
1317
|
-
const secretShape = looksLikeApiSecret(value);
|
|
1318
|
-
json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
|
|
1319
|
-
} catch (error) {
|
|
1320
|
-
// A ref supplied by the launching environment is read-only, and the
|
|
1321
|
-
// service says so in plain words — pass that through to the card.
|
|
1322
|
-
json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
|
|
1323
|
-
}
|
|
1324
|
-
},
|
|
1325
|
-
}), 'dsh-key-rotation: key route');
|
|
1326
|
-
|
|
1327
|
-
// ── reset route: clear cooldown for a provider (or a single ref) ──
|
|
1328
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1329
|
-
kind: 'exact',
|
|
1330
|
-
path: RESET_PATH,
|
|
1331
|
-
handler: async (req, res) => {
|
|
1332
|
-
if (req.method !== 'POST') {
|
|
1333
|
-
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
1334
|
-
return;
|
|
1335
|
-
}
|
|
1336
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
1337
|
-
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
let body;
|
|
1341
|
-
try { body = await readJson(req); } catch (e) {
|
|
1342
|
-
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
1346
|
-
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1347
|
-
if (provider) {
|
|
1348
|
-
const st = poolState.get(provider);
|
|
1349
|
-
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
1350
|
-
const cleared = st.failedUntil.size;
|
|
1351
|
-
st.failedUntil.clear();
|
|
1352
|
-
st.failCounts?.clear();
|
|
1353
|
-
st.authFailCounts?.clear();
|
|
1354
|
-
st.brokenUntil?.clear();
|
|
1355
|
-
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
1356
|
-
json(res, 200, { ok: true, provider, cleared });
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
if (ref) {
|
|
1360
|
-
let found = false;
|
|
1361
|
-
for (const st of poolState.values()) {
|
|
1362
|
-
if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
|
|
1363
|
-
st.failedUntil.delete(ref);
|
|
1364
|
-
st.failCounts?.delete(ref);
|
|
1365
|
-
st.authFailCounts?.delete(ref);
|
|
1366
|
-
st.brokenUntil?.delete(ref);
|
|
1367
|
-
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
1368
|
-
found = true; break;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
1372
|
-
if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
1373
|
-
json(res, 200, { ok: true, ref });
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
|
|
1377
|
-
},
|
|
1378
|
-
}), 'dsh-key-rotation: reset route');
|
|
1379
|
-
|
|
1380
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1381
|
-
kind: 'exact',
|
|
1382
|
-
path: IMPORT_PATH,
|
|
1383
|
-
handler: async (req, res) => {
|
|
1384
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1385
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
|
|
1386
|
-
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1387
|
-
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
1388
|
-
if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
|
|
1389
|
-
try {
|
|
1390
|
-
const resp = await fetch(url);
|
|
1391
|
-
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
1392
|
-
const data = await resp.json();
|
|
1393
|
-
if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
|
|
1394
|
-
const settings = ctx.get('settings');
|
|
1395
|
-
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
1396
|
-
const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
|
|
1397
|
-
const cur = desc?.value?.providers ?? [];
|
|
1398
|
-
const merged = new Map();
|
|
1399
|
-
for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
|
|
1400
|
-
for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
|
|
1401
|
-
const mergedArr = [...merged.values()];
|
|
1402
|
-
await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
|
|
1403
|
-
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
1404
|
-
} catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
|
|
1405
|
-
},
|
|
1406
|
-
}), 'dsh-key-rotation: import route');
|
|
1407
|
-
|
|
1408
|
-
// Health for external panels (Beszel/Uptime)
|
|
1409
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1410
|
-
kind: 'exact',
|
|
1411
|
-
path: HEALTH_PATH,
|
|
1412
|
-
handler: async (req, res) => {
|
|
1413
|
-
if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
|
|
1414
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
1415
|
-
// also allow plain loopback without Origin
|
|
1416
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
|
|
1417
|
-
if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
|
|
1418
|
-
}
|
|
1419
|
-
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
1420
|
-
const now = Date.now();
|
|
1421
|
-
const pools = {};
|
|
1422
|
-
let exhaustedAny = false;
|
|
1423
|
-
const { poolByRef: pr, providerTags } = buildRuntime();
|
|
1424
|
-
const seenH = new Set();
|
|
1425
|
-
for (const pool of pr.values()) {
|
|
1426
|
-
if (seenH.has(pool.base)) continue;
|
|
1427
|
-
seenH.add(pool.base);
|
|
1428
|
-
let healthy = 0;
|
|
1429
|
-
for (const ref of pool.refs) {
|
|
1430
|
-
const until = pool.state.failedUntil.get(ref);
|
|
1431
|
-
if (until !== undefined && until > now) continue;
|
|
1432
|
-
const exp = pool.expiresAt?.[ref];
|
|
1433
|
-
if (exp !== undefined && now >= exp) continue;
|
|
1434
|
-
healthy++;
|
|
1435
|
-
}
|
|
1436
|
-
const total = pool.refs.length;
|
|
1437
|
-
const exhausted = healthy === 0 && total > 0;
|
|
1438
|
-
if (exhausted) exhaustedAny = true;
|
|
1439
|
-
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
1440
|
-
}
|
|
1441
|
-
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: quotaStore.snapshot() });
|
|
1442
|
-
},
|
|
1443
|
-
}), 'dsh-key-rotation: health');
|
|
1444
|
-
|
|
1445
|
-
// ── test route: dry-run a single key without rotation ──
|
|
1446
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1447
|
-
kind: 'exact',
|
|
1448
|
-
path: TEST_PATH,
|
|
1449
|
-
handler: async (req, res) => {
|
|
1450
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1451
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
|
|
1452
|
-
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1453
|
-
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1454
|
-
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
1455
|
-
// Optional value for pre-save validation (issue #118)
|
|
1456
|
-
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
1457
|
-
const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
|
|
1458
|
-
const base = ctx.get('credentials');
|
|
1459
|
-
try {
|
|
1460
|
-
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
1461
|
-
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
1462
|
-
const effectiveValue = testValue || hit?.value;
|
|
1463
|
-
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
1464
|
-
const tail = valid ? keyTail(effectiveValue) : '';
|
|
1465
|
-
let source = null;
|
|
1466
|
-
try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
|
|
1467
|
-
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
1468
|
-
if (!present && testValue) { source = 'pre-save'; }
|
|
1469
|
-
else if (!present) {
|
|
1470
|
-
const ev = envValue(ref);
|
|
1471
|
-
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
1472
|
-
}
|
|
1473
|
-
// sandbox probe (models is free; chat is hook-only, see sandbox.js)
|
|
1474
|
-
if (probe) {
|
|
1475
|
-
const keyForProbe = effectiveValue;
|
|
1476
|
-
const runner = ensureSandboxRunner(ctx);
|
|
1477
|
-
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
1478
|
-
const cached = { ...result, at: Date.now() };
|
|
1479
|
-
lastTestCache.set(ref, cached);
|
|
1480
|
-
if (cached.ok) {
|
|
1481
|
-
for (const st of poolState.values()) {
|
|
1482
|
-
if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
|
|
1483
|
-
st.failedUntil?.delete(ref);
|
|
1484
|
-
st.failCounts?.delete(ref);
|
|
1485
|
-
st.authFailCounts?.delete(ref);
|
|
1486
|
-
st.brokenUntil?.delete(ref);
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
json(res, 200, { ok: true, ref, tail, source });
|
|
1494
|
-
} catch (e) {
|
|
1495
|
-
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
1496
|
-
}
|
|
1497
|
-
},
|
|
1498
|
-
}), 'dsh-key-rotation: test route');
|
|
1499
|
-
|
|
1500
|
-
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
1501
|
-
// to a configured key pool; pass everything else (and internal dispatches)
|
|
1502
|
-
// straight through.
|
|
1503
|
-
// Read-only cache snapshot for clients (badge polling).
|
|
1504
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1505
|
-
kind: 'exact',
|
|
1506
|
-
path: SANDBOX_CACHE_PATH,
|
|
1507
|
-
handler: (req, res) => {
|
|
1508
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
|
|
1509
|
-
json(res, 200, lastTestCache.snapshot());
|
|
1510
|
-
},
|
|
1511
|
-
}), 'dsh-key-rotation: sandbox cache');
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
// #199 webhook-action: interactive webhook buttons call back here.
|
|
1516
|
-
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
1517
|
-
// cannot be same-origin, so a shared secret is the gate).
|
|
1518
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1519
|
-
kind: 'exact',
|
|
1520
|
-
path: '/dsh-key-rotation/webhook-action',
|
|
1521
|
-
handler: async (req, res) => {
|
|
1522
|
-
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1523
|
-
const runtime = buildRuntime();
|
|
1524
|
-
const expected = runtime.webhookActionToken;
|
|
1525
|
-
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
1526
|
-
const auth = String(req.headers.authorization ?? '');
|
|
1527
|
-
if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
|
|
1528
|
-
let body;
|
|
1529
|
-
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1530
|
-
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
1531
|
-
let action = typeof body?.action === 'string' ? body.action : '';
|
|
1532
|
-
if (!action && typeof body?.data === 'string') {
|
|
1533
|
-
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
1534
|
-
}
|
|
1535
|
-
if (!action && typeof body?.callback_data === 'string') {
|
|
1536
|
-
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
1537
|
-
}
|
|
1538
|
-
// #222: Telegram update envelope {update_id, callback_query:{data}}
|
|
1539
|
-
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
1540
|
-
try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
|
|
1541
|
-
}
|
|
1542
|
-
// #222: Telegram setWebhook registration helper
|
|
1543
|
-
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
1544
|
-
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
1545
|
-
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
1546
|
-
// derive the public URL from request headers; explicit URL wins
|
|
1547
|
-
const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
|
|
1548
|
-
try {
|
|
1549
|
-
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
1550
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1551
|
-
body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
|
|
1552
|
-
});
|
|
1553
|
-
const hookData = await hookRes.json().catch(() => ({}));
|
|
1554
|
-
json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
|
|
1555
|
-
} catch (e) {
|
|
1556
|
-
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
1557
|
-
}
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
|
|
1561
|
-
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
1562
|
-
try {
|
|
1563
|
-
if (action === 'disable-rotation') {
|
|
1564
|
-
rotationDisabled = true;
|
|
1565
|
-
console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
1566
|
-
json(res, 200, { ok: true, action });
|
|
1567
|
-
return;
|
|
1568
|
-
}
|
|
1569
|
-
if (action === 'enable-rotation') {
|
|
1570
|
-
rotationDisabled = false;
|
|
1571
|
-
json(res, 200, { ok: true, action });
|
|
1572
|
-
return;
|
|
1573
|
-
}
|
|
1574
|
-
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
1575
|
-
const st = poolState.get(provider);
|
|
1576
|
-
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
1577
|
-
if (action.startsWith('pause-')) {
|
|
1578
|
-
const until = Date.now() + 3600000; // 1h pause
|
|
1579
|
-
for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
1580
|
-
// also pause every key currently healthy
|
|
1581
|
-
for (const p of buildRuntime().poolByRef.values()) {
|
|
1582
|
-
if (p.base !== provider) continue;
|
|
1583
|
-
for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
1584
|
-
}
|
|
1585
|
-
console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
1586
|
-
json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
|
|
1587
|
-
return;
|
|
1588
|
-
}
|
|
1589
|
-
const cleared = st.failedUntil.size;
|
|
1590
|
-
st.failedUntil.clear(); st.failCounts?.clear(); st.brokenUntil?.clear();
|
|
1591
|
-
console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action`);
|
|
1592
|
-
json(res, 200, { ok: true, action, provider, cleared });
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
|
|
1596
|
-
} catch (e) {
|
|
1597
|
-
json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
|
|
1598
|
-
}
|
|
1599
|
-
},
|
|
1600
|
-
}), 'dsh-key-rotation: webhook-action');
|
|
1601
|
-
|
|
1602
|
-
|
|
741
|
+
// Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
|
|
742
|
+
registerOpsRoutes(ctx, {
|
|
743
|
+
buildRuntime,
|
|
744
|
+
latencyHistogram,
|
|
745
|
+
lastTestCache,
|
|
746
|
+
ensureSandboxRunner,
|
|
747
|
+
poolState,
|
|
748
|
+
getRotationDisabled: () => rotationDisabled,
|
|
749
|
+
setRotationDisabled: (v) => { rotationDisabled = v; },
|
|
750
|
+
});
|
|
1603
751
|
|
|
1604
752
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
1605
753
|
if (options[MARKER]) return next();
|
|
@@ -1608,7 +756,9 @@ export function apply(ctx, config = {}) {
|
|
|
1608
756
|
// #195: exact model pool -> longest model-family prefix -> provider pool
|
|
1609
757
|
const pool = selectPool(modelPoolByProvider, providerToPool, options.provider, options.model);
|
|
1610
758
|
if (!pool) return next();
|
|
1611
|
-
|
|
759
|
+
if (buildRuntime()?.verboseLogging) {
|
|
760
|
+
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
|
|
761
|
+
}
|
|
1612
762
|
return rotate(options, pool);
|
|
1613
763
|
}), 'dsh-key-rotation: llm/stream');
|
|
1614
764
|
|
|
@@ -1627,11 +777,13 @@ export function apply(ctx, config = {}) {
|
|
|
1627
777
|
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
1628
778
|
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
1629
779
|
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1630
|
-
const
|
|
780
|
+
const cls = classifyFailure(payload);
|
|
781
|
+
const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
|
|
1631
782
|
if (!switchable) return next();
|
|
783
|
+
if (moduleBreaker) moduleBreaker.onFailure(provider);
|
|
1632
784
|
const ref = pool.state.lastUsed;
|
|
1633
785
|
if (ref) {
|
|
1634
|
-
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000);
|
|
786
|
+
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft);
|
|
1635
787
|
pushEvent(pool, ref, code || 'UNKNOWN', backoff);
|
|
1636
788
|
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1637
789
|
pool.state.lastReason = code || 'UNKNOWN';
|
|
@@ -1673,7 +825,8 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
|
|
|
1673
825
|
{ id: `pause-${options.provider}`, label: 'Pause 1h' },
|
|
1674
826
|
] : undefined,
|
|
1675
827
|
};
|
|
1676
|
-
hooks.
|
|
828
|
+
if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
|
|
829
|
+
else hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
1677
830
|
}
|
|
1678
831
|
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1679
832
|
}
|