@goodandready/dsh-key-rotation 0.7.31 → 0.7.32
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 +9 -0
- package/README.ru.md +9 -0
- package/README.zh.md +9 -0
- package/lib/client.js +72 -2
- package/lib/concurrency.js +73 -72
- package/lib/index.js +1850 -1836
- package/lib/quota-window.js +45 -45
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,1836 +1,1850 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
3
|
-
//
|
|
4
|
-
// Transparent key rotation, Hermes-style: every configured provider has a KEY
|
|
5
|
-
// POOL (env refs). The plugin patches `ctx.credentials.resolve` so a pool ref
|
|
6
|
-
// resolves to the next available key (round-robin, skipping keys in cooldown),
|
|
7
|
-
// and intercepts `llm/stream` to retry a request on the next key when the
|
|
8
|
-
// current one fails with a switchable error (QUOTA, RATE_LIMIT, ...) before
|
|
9
|
-
// any content chunk.
|
|
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
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
-
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
-
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool } from './pool.js';
|
|
37
|
-
|
|
38
|
-
export const name = 'dsh-key-rotation';
|
|
39
|
-
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
40
|
-
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
|
|
41
|
-
|
|
42
|
-
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
43
|
-
const NS = 'dsh-key-rotation';
|
|
44
|
-
/** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
|
|
45
|
-
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
46
|
-
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
47
|
-
const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
48
|
-
const KEY_PATH = '/dsh-key-rotation/key';
|
|
49
|
-
const RESET_PATH = '/dsh-key-rotation/reset';
|
|
50
|
-
const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
51
|
-
const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
52
|
-
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
53
|
-
const TEST_PATH = '/dsh-key-rotation/test';
|
|
54
|
-
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
55
|
-
const AGENT_BUDGET_PATH = '/dsh-key-rotation/agent-budget';
|
|
56
|
-
const REGIONS_PATH = '/dsh-key-rotation/regions';
|
|
57
|
-
const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
|
|
58
|
-
const SHADOW_PATH = '/dsh-key-rotation/shadow';
|
|
59
|
-
const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
|
|
60
|
-
const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
|
|
61
|
-
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
62
|
-
import { healIdleCooldowns } from './heal.js';
|
|
63
|
-
import { LatencyHistogram } from './histogram.js';
|
|
64
|
-
import { pickCascadeFallback } from './cascade.js';
|
|
65
|
-
import { ConcurrencyTracker } from './concurrency.js';
|
|
66
|
-
import { nextQuotaReset } from './quota-window.js';
|
|
67
|
-
import { CanaryProber } from './canary.js';
|
|
68
|
-
import { QuotaStore } from './quota.js';
|
|
69
|
-
import { AgentBudget } from './agent-budget.js';
|
|
70
|
-
import { RegionMap } from './region.js';
|
|
71
|
-
import { IncidentReporter } from './incident.js';
|
|
72
|
-
import { ShadowRouter } from './shadow.js';
|
|
73
|
-
import { WebhookSender } from './webhook.js';
|
|
74
|
-
import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
|
|
75
|
-
import { expiringSoon, shouldNotifyDaily, costForDay, budgetVerdict, costForWeek } from './maintenance.js';
|
|
76
|
-
import { usageRows, usageCsv } from './usage-report.js';
|
|
77
|
-
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
78
|
-
|
|
79
|
-
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
80
|
-
const PIAI_NS = 'llm-pi-ai';
|
|
81
|
-
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
82
|
-
const MARKER = '__dshKeyRotation';
|
|
83
|
-
/** #199: set true via webhook action; checked in the llm/stream interceptor. */
|
|
84
|
-
let rotationDisabled = false;
|
|
85
|
-
// #207/#208 dedupe maps: one notification per key/window per day.
|
|
86
|
-
const expiryNotifiedAt = new Map();
|
|
87
|
-
const budgetNotifiedAt = new Map();
|
|
88
|
-
const switchNotifiedAt = new Map();
|
|
89
|
-
const lowHealthNotifiedAt = new Map();
|
|
90
|
-
const sloNotifiedAt = new Map();
|
|
91
|
-
const DAY_MS = 86400000;
|
|
92
|
-
|
|
93
|
-
// #216: one webhook per switch, deduped to at most one message per provider
|
|
94
|
-
// per switchNotifyThrottleMs. Extracted for testability.
|
|
95
|
-
export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
|
|
96
|
-
if (!runtime?.notifyWebhook) return;
|
|
97
|
-
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
98
|
-
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
99
|
-
const now = hooks.now();
|
|
100
|
-
if (now - last < throttle) return;
|
|
101
|
-
switchNotifiedAt.set(info.provider, now);
|
|
102
|
-
hooks.webhookSender.send(runtime.notifyWebhook, {
|
|
103
|
-
title: `Key switched: ${info.provider}`,
|
|
104
|
-
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
105
|
-
provider: info.provider,
|
|
106
|
-
kind: 'switch',
|
|
107
|
-
from: info.from,
|
|
108
|
-
code: info.code,
|
|
109
|
-
at: info.at,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
const MAX_EVENTS = 50;
|
|
113
|
-
function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
114
|
-
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
115
|
-
pool.state.events.push(ev);
|
|
116
|
-
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Fallback classification by failure message. pi-ai surfaces many real quota /
|
|
121
|
-
// rate-limit / transport failures as thrown exceptions (e.g. the OpenAI SDK
|
|
122
|
-
// throws on HTTP 429 before the stream starts), and dsh-llm then normalizes
|
|
123
|
-
// them to finish chunks with code "UNKNOWN". The message still carries the
|
|
124
|
-
// provider's own text ("429: ...", "Weekly usage limit reached", ...), so we
|
|
125
|
-
// treat pre-content failures whose message matches these patterns as
|
|
126
|
-
// switchable even when the code is not in `switchCodes`.
|
|
127
|
-
|
|
128
|
-
// Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
|
|
129
|
-
let lastTestCacheRunnerCtx = null;
|
|
130
|
-
const lastTestCache = new LastTestCache();
|
|
131
|
-
const latencyHistogram = new LatencyHistogram();
|
|
132
|
-
const quotaStore = new QuotaStore();
|
|
133
|
-
const agentBudget = new AgentBudget();
|
|
134
|
-
const regionMap = new RegionMap();
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
let
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
// the
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
json(res,
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
writable: settings?.writable ?? false,
|
|
345
|
-
hasDocument: settings?.documentPath !== void 0,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
// #
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
const
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if (
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
if (
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
pool.state.
|
|
885
|
-
|
|
886
|
-
if (
|
|
887
|
-
if (
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
pool.state.
|
|
902
|
-
pool.state.
|
|
903
|
-
if (pool.
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
}
|
|
1060
|
-
//
|
|
1061
|
-
if (pool.state.lastUsed) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
if (
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
//
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
const
|
|
1334
|
-
json(res,
|
|
1335
|
-
} catch
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
json(res,
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
if (!
|
|
1657
|
-
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
const
|
|
1707
|
-
st
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
json(res, 200,
|
|
1740
|
-
},
|
|
1741
|
-
}), 'dsh-key-rotation:
|
|
1742
|
-
|
|
1743
|
-
//
|
|
1744
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1745
|
-
kind: 'exact',
|
|
1746
|
-
path:
|
|
1747
|
-
handler: (req, res) => {
|
|
1748
|
-
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation:
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
const
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
3
|
+
//
|
|
4
|
+
// Transparent key rotation, Hermes-style: every configured provider has a KEY
|
|
5
|
+
// POOL (env refs). The plugin patches `ctx.credentials.resolve` so a pool ref
|
|
6
|
+
// resolves to the next available key (round-robin, skipping keys in cooldown),
|
|
7
|
+
// and intercepts `llm/stream` to retry a request on the next key when the
|
|
8
|
+
// current one fails with a switchable error (QUOTA, RATE_LIMIT, ...) before
|
|
9
|
+
// any content chunk.
|
|
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
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
+
import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure, recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter, computeHealthScore, extractRateLimit, isRateLimited, selectPool } from './pool.js';
|
|
37
|
+
|
|
38
|
+
export const name = 'dsh-key-rotation';
|
|
39
|
+
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
40
|
+
export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
|
|
41
|
+
|
|
42
|
+
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
43
|
+
const NS = 'dsh-key-rotation';
|
|
44
|
+
/** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
|
|
45
|
+
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
46
|
+
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
47
|
+
const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
48
|
+
const KEY_PATH = '/dsh-key-rotation/key';
|
|
49
|
+
const RESET_PATH = '/dsh-key-rotation/reset';
|
|
50
|
+
const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
51
|
+
const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
52
|
+
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
53
|
+
const TEST_PATH = '/dsh-key-rotation/test';
|
|
54
|
+
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
55
|
+
const AGENT_BUDGET_PATH = '/dsh-key-rotation/agent-budget';
|
|
56
|
+
const REGIONS_PATH = '/dsh-key-rotation/regions';
|
|
57
|
+
const INCIDENT_RESET_PATH = '/dsh-key-rotation/incident-reset';
|
|
58
|
+
const SHADOW_PATH = '/dsh-key-rotation/shadow';
|
|
59
|
+
const WEBHOOK_TEST_PATH = '/dsh-key-rotation/webhook-test';
|
|
60
|
+
const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
|
|
61
|
+
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
62
|
+
import { healIdleCooldowns } from './heal.js';
|
|
63
|
+
import { LatencyHistogram } from './histogram.js';
|
|
64
|
+
import { pickCascadeFallback } from './cascade.js';
|
|
65
|
+
import { ConcurrencyTracker } from './concurrency.js';
|
|
66
|
+
import { nextQuotaReset } from './quota-window.js';
|
|
67
|
+
import { CanaryProber } from './canary.js';
|
|
68
|
+
import { QuotaStore } from './quota.js';
|
|
69
|
+
import { AgentBudget } from './agent-budget.js';
|
|
70
|
+
import { RegionMap } from './region.js';
|
|
71
|
+
import { IncidentReporter } from './incident.js';
|
|
72
|
+
import { ShadowRouter } from './shadow.js';
|
|
73
|
+
import { WebhookSender } from './webhook.js';
|
|
74
|
+
import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
|
|
75
|
+
import { expiringSoon, shouldNotifyDaily, costForDay, budgetVerdict, costForWeek } from './maintenance.js';
|
|
76
|
+
import { usageRows, usageCsv } from './usage-report.js';
|
|
77
|
+
import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
78
|
+
|
|
79
|
+
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
80
|
+
const PIAI_NS = 'llm-pi-ai';
|
|
81
|
+
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
82
|
+
const MARKER = '__dshKeyRotation';
|
|
83
|
+
/** #199: set true via webhook action; checked in the llm/stream interceptor. */
|
|
84
|
+
let rotationDisabled = false;
|
|
85
|
+
// #207/#208 dedupe maps: one notification per key/window per day.
|
|
86
|
+
const expiryNotifiedAt = new Map();
|
|
87
|
+
const budgetNotifiedAt = new Map();
|
|
88
|
+
const switchNotifiedAt = new Map();
|
|
89
|
+
const lowHealthNotifiedAt = new Map();
|
|
90
|
+
const sloNotifiedAt = new Map();
|
|
91
|
+
const DAY_MS = 86400000;
|
|
92
|
+
|
|
93
|
+
// #216: one webhook per switch, deduped to at most one message per provider
|
|
94
|
+
// per switchNotifyThrottleMs. Extracted for testability.
|
|
95
|
+
export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
|
|
96
|
+
if (!runtime?.notifyWebhook) return;
|
|
97
|
+
const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
|
|
98
|
+
const last = switchNotifiedAt.get(info.provider) ?? 0;
|
|
99
|
+
const now = hooks.now();
|
|
100
|
+
if (now - last < throttle) return;
|
|
101
|
+
switchNotifiedAt.set(info.provider, now);
|
|
102
|
+
hooks.webhookSender.send(runtime.notifyWebhook, {
|
|
103
|
+
title: `Key switched: ${info.provider}`,
|
|
104
|
+
text: `${info.from} failed (${info.code}) - next key in pool`,
|
|
105
|
+
provider: info.provider,
|
|
106
|
+
kind: 'switch',
|
|
107
|
+
from: info.from,
|
|
108
|
+
code: info.code,
|
|
109
|
+
at: info.at,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const MAX_EVENTS = 50;
|
|
113
|
+
function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
114
|
+
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
115
|
+
pool.state.events.push(ev);
|
|
116
|
+
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// Fallback classification by failure message. pi-ai surfaces many real quota /
|
|
121
|
+
// rate-limit / transport failures as thrown exceptions (e.g. the OpenAI SDK
|
|
122
|
+
// throws on HTTP 429 before the stream starts), and dsh-llm then normalizes
|
|
123
|
+
// them to finish chunks with code "UNKNOWN". The message still carries the
|
|
124
|
+
// provider's own text ("429: ...", "Weekly usage limit reached", ...), so we
|
|
125
|
+
// treat pre-content failures whose message matches these patterns as
|
|
126
|
+
// switchable even when the code is not in `switchCodes`.
|
|
127
|
+
|
|
128
|
+
// Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
|
|
129
|
+
let lastTestCacheRunnerCtx = null;
|
|
130
|
+
const lastTestCache = new LastTestCache();
|
|
131
|
+
const latencyHistogram = new LatencyHistogram();
|
|
132
|
+
const quotaStore = new QuotaStore();
|
|
133
|
+
const agentBudget = new AgentBudget();
|
|
134
|
+
const regionMap = new RegionMap();
|
|
135
|
+
// Global config accessor safe against early initialization
|
|
136
|
+
let getConfig = () => null;
|
|
137
|
+
|
|
138
|
+
// IncidentReporter: lazily built when Config provides incidentGitHubToken + incidentGitHubBaseUrl.
|
|
139
|
+
// ponytail: never bake the token into source; repo is hardcoded (this plugin's home repo) but token is per-deploy.
|
|
140
|
+
let incidentReporter = null;
|
|
141
|
+
function ensureIncidentReporter() {
|
|
142
|
+
if (incidentReporter) return incidentReporter;
|
|
143
|
+
const cfg = getConfig();
|
|
144
|
+
const token = cfg ? cfg.incidentGitHubToken : '';
|
|
145
|
+
const baseUrl = cfg ? cfg.incidentGitHubBaseUrl : '';
|
|
146
|
+
if (!token || !baseUrl) return null;
|
|
147
|
+
incidentReporter = new IncidentReporter({ token, baseUrl, repo: 'goodandready/dsh-key-rotation', fetchImpl: globalThis.fetch });
|
|
148
|
+
return incidentReporter;
|
|
149
|
+
}
|
|
150
|
+
const shadowRouter = new ShadowRouter({ primary: '', secondary: '', percent: 0 });let sandboxRunner = null;
|
|
151
|
+
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
152
|
+
const concurrencyTracker = new ConcurrencyTracker();
|
|
153
|
+
let canaryProber = null;
|
|
154
|
+
function ensureSandboxRunner(ctx) {
|
|
155
|
+
if (sandboxRunner) return sandboxRunner;
|
|
156
|
+
// provider id or key ref -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
157
|
+
function resolveBaseUrl(providerOrRef) {
|
|
158
|
+
try {
|
|
159
|
+
let provider = providerOrRef;
|
|
160
|
+
const rt = buildRuntime();
|
|
161
|
+
const pool = rt?.poolByRef?.get(providerOrRef);
|
|
162
|
+
if (pool?.base) provider = pool.base;
|
|
163
|
+
else if (pool?.provider) provider = pool.provider;
|
|
164
|
+
|
|
165
|
+
const c = ctx || lastTestCacheRunnerCtx;
|
|
166
|
+
const ns = c?.get ? c.get(PIAI_NS) : null;
|
|
167
|
+
const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
|
|
168
|
+
if (!Array.isArray(list)) return null;
|
|
169
|
+
// ponytail: match by id OR name OR alias; pick first hit
|
|
170
|
+
const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
|
|
171
|
+
const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
|
|
172
|
+
return base ? String(base) : null;
|
|
173
|
+
} catch (_) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
|
|
178
|
+
return sandboxRunner;
|
|
179
|
+
}
|
|
180
|
+
async function probeRef(ref, key) {
|
|
181
|
+
// ref may be like "PROVIDER/KEY_NAME" — for sandbox we only care about the credential ref
|
|
182
|
+
// (the resolveBaseUrl uses the full provider id; ref can carry any string)
|
|
183
|
+
const runner = ensureSandboxRunner(lastTestCacheRunnerCtx);
|
|
184
|
+
const result = await runner.probeModels(ref, key);
|
|
185
|
+
lastTestCache.set(ref, { ...result, at: Date.now() });
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// // Bootstrap key pools. The user configures them in the Settings GUI or via
|
|
190
|
+
// the dsh profile bundle config; the plugin itself ships no provider defaults
|
|
191
|
+
// so it does not bind to any specific installation. Empty array means: until
|
|
192
|
+
// the user adds a pool, no rotation happens, and every provider falls back to
|
|
193
|
+
// its single configured credential exactly as before this plugin was installed.
|
|
194
|
+
const DEFAULT_PROVIDERS = [];
|
|
195
|
+
|
|
196
|
+
export const Config = Schema.object({
|
|
197
|
+
switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
|
|
198
|
+
cooldownMs: Schema.number().default(60000),
|
|
199
|
+
maxCooldownMs: Schema.number(),
|
|
200
|
+
notifyWebhook: Schema.string().default(''),
|
|
201
|
+
notifyThreshold: Schema.number().default(3),
|
|
202
|
+
backupDir: Schema.string().default(''),
|
|
203
|
+
backupIntervalMs: Schema.number().default(86400000),
|
|
204
|
+
backupKeep: Schema.number().default(7),
|
|
205
|
+
rotationScheduleDays: Schema.number().default(0),
|
|
206
|
+
selfHealCooldown: Schema.boolean().default(true),
|
|
207
|
+
selfHealIdleMs: Schema.number().default(3600000),
|
|
208
|
+
latencyEnabled: Schema.boolean().default(true),
|
|
209
|
+
latencyWindow: Schema.number().default(200),
|
|
210
|
+
incidentGitHubToken: Schema.string().default(''),
|
|
211
|
+
incidentGitHubBaseUrl: Schema.string().default(''),
|
|
212
|
+
incidentThreshold: Schema.number().default(5),
|
|
213
|
+
concurrencyLimit: Schema.number().default(0),
|
|
214
|
+
canaryProbingEnabled: Schema.boolean().default(false),
|
|
215
|
+
canaryIntervalMs: Schema.number().default(30000),
|
|
216
|
+
cascade: Schema.array(Schema.object({
|
|
217
|
+
provider: Schema.string().required(),
|
|
218
|
+
model: Schema.string(),
|
|
219
|
+
})).default([]),
|
|
220
|
+
quotaResetWindow: Schema.object({
|
|
221
|
+
type: Schema.string().default('midnight_utc'),
|
|
222
|
+
hour: Schema.number().default(0),
|
|
223
|
+
}),
|
|
224
|
+
rateLimitThreshold: Schema.number().default(0.1),
|
|
225
|
+
rpmLimit: Schema.number().default(0),
|
|
226
|
+
webhookActionToken: Schema.string().default(''),
|
|
227
|
+
expiryWarnDays: Schema.number().default(7),
|
|
228
|
+
switchNotify: Schema.boolean().default(false),
|
|
229
|
+
switchNotifyThrottleMs: Schema.number().default(60000),
|
|
230
|
+
warnBelowHealthy: Schema.number().default(0),
|
|
231
|
+
latencySloMs: Schema.number().default(0),
|
|
232
|
+
providers: Schema.array(Schema.object({
|
|
233
|
+
provider: Schema.string().required(),
|
|
234
|
+
keys: Schema.array(Schema.string()).default([]),
|
|
235
|
+
weights: Schema.array(Schema.number()).default([]),
|
|
236
|
+
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
237
|
+
tags: Schema.array(Schema.string()).default([]),
|
|
238
|
+
costBudgetDaily: Schema.number(),
|
|
239
|
+
costBudgetWeekly: Schema.number(),
|
|
240
|
+
pauseOnBudget: Schema.boolean().default(false),
|
|
241
|
+
models: Schema.dict(Schema.object({
|
|
242
|
+
keys: Schema.array(Schema.string()).default([]),
|
|
243
|
+
weights: Schema.array(Schema.number()).default([]),
|
|
244
|
+
})).default({}),
|
|
245
|
+
cooldownMs: Schema.number(),
|
|
246
|
+
maxCooldownMs: Schema.number(),
|
|
247
|
+
})).default([...DEFAULT_PROVIDERS]),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── config bridge (GET/PUT/DELETE on CONFIG_PATH), mirroring llm-fallback ──
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
function json(res, status, obj) {
|
|
254
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
255
|
+
res.end(JSON.stringify(obj));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readJson(request) {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
let raw = '';
|
|
261
|
+
request.on('data', (c) => { raw += c; });
|
|
262
|
+
request.on('end', () => {
|
|
263
|
+
try {
|
|
264
|
+
resolve(JSON.parse(raw || '{}'));
|
|
265
|
+
} catch (e) {
|
|
266
|
+
reject(e);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
request.on('error', reject);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function descriptorOf(ctx, ns) {
|
|
274
|
+
const settings = ctx.get('settings');
|
|
275
|
+
if (settings === void 0) return void 0;
|
|
276
|
+
return settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function viewOf(descriptor, settings) {
|
|
280
|
+
return {
|
|
281
|
+
available: true,
|
|
282
|
+
writable: settings.writable,
|
|
283
|
+
hasDocument: settings.hasDocument,
|
|
284
|
+
value: descriptor.value,
|
|
285
|
+
...descriptor.base === void 0 ? {} : { base: descriptor.base },
|
|
286
|
+
...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
|
|
287
|
+
revision: descriptor.revision,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function writeSection(ctx, ns, section, expectedRevision, res) {
|
|
292
|
+
const settings = ctx.get('settings');
|
|
293
|
+
if (settings === void 0) {
|
|
294
|
+
json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider is mounted' } });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
await settings.replace(ns, section, expectedRevision);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error?.code === 'SETTINGS_CONFLICT') {
|
|
301
|
+
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` } });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
json(res, 400, { error: { code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) } });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const descriptor = descriptorOf(ctx, ns);
|
|
308
|
+
if (descriptor === void 0) {
|
|
309
|
+
json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Provider catalog for the GUI dropdown, minus clone routes of configured chains. */
|
|
316
|
+
function providerCatalog(ctx, cloneIds) {
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const info of ctx.llm.listProviders()) {
|
|
320
|
+
if (seen.has(info.id) || cloneIds.has(info.id)) continue;
|
|
321
|
+
seen.add(info.id);
|
|
322
|
+
out.push({ id: info.id, name: info.name ?? info.id });
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
328
|
+
if (!isTrustedBridgeRequest(request)) {
|
|
329
|
+
res.writeHead(403);
|
|
330
|
+
res.end();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const method = request.method ?? 'GET';
|
|
334
|
+
if (method === 'GET') {
|
|
335
|
+
const settings = ctx.get('settings');
|
|
336
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
337
|
+
const body = {
|
|
338
|
+
providers: providerCatalog(ctx, getCloneIds()),
|
|
339
|
+
};
|
|
340
|
+
if (descriptor === void 0) {
|
|
341
|
+
json(res, 200, {
|
|
342
|
+
...body,
|
|
343
|
+
available: false,
|
|
344
|
+
writable: settings?.writable ?? false,
|
|
345
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
346
|
+
value: void 0,
|
|
347
|
+
revision: 0,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
json(res, 200, {
|
|
352
|
+
...body,
|
|
353
|
+
...viewOf(descriptor, {
|
|
354
|
+
writable: settings?.writable ?? false,
|
|
355
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (method === 'PUT' || method === 'DELETE') {
|
|
361
|
+
let section;
|
|
362
|
+
let expectedRevision;
|
|
363
|
+
if (method === 'PUT') {
|
|
364
|
+
let body;
|
|
365
|
+
try {
|
|
366
|
+
body = await readJson(request);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
json(res, 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (typeof body !== 'object' || body === null || typeof body.section !== 'object' || body.section === null || Array.isArray(body.section)) {
|
|
372
|
+
json(res, 400, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: PUT requires {"section": {...}}' } });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
section = body.section;
|
|
376
|
+
expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : void 0;
|
|
377
|
+
// #200 leak detector: a live secret pasted into the config section is
|
|
378
|
+
// almost always a mistake (real key values belong in PUT /key). The two
|
|
379
|
+
// fields that legitimately hold tokens are masked before scanning.
|
|
380
|
+
try {
|
|
381
|
+
const masked = structuredClone(section);
|
|
382
|
+
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
383
|
+
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
384
|
+
// notifyWebhook legitimately carries bot tokens inside URLs
|
|
385
|
+
// (api.telegram.org/bot<token>/...) - scan it for nothing.
|
|
386
|
+
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
387
|
+
const findings = findSecrets(JSON.stringify(masked));
|
|
388
|
+
if (findings.length > 0) {
|
|
389
|
+
json(res, 400, {
|
|
390
|
+
error: {
|
|
391
|
+
code: 'secret-in-config',
|
|
392
|
+
message: `dsh-key-rotation: value looks like a live credential (${findings[0].type}); store key values via the key field, not the config section`,
|
|
393
|
+
findings,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
/* scanning must never block a valid save */
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
section = {};
|
|
403
|
+
}
|
|
404
|
+
await writeSection(ctx, NS, section, expectedRevision, res);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
res.writeHead(405);
|
|
408
|
+
res.end();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function registerConfigBridge(ctx, getCloneIds) {
|
|
412
|
+
return ctx.webServer.register({
|
|
413
|
+
kind: 'exact',
|
|
414
|
+
path: CONFIG_PATH,
|
|
415
|
+
handler: (req, res) => void handleConfigBridge(ctx, req, res, getCloneIds),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── plugin ──
|
|
420
|
+
|
|
421
|
+
export function apply(ctx, config = {}) {
|
|
422
|
+
// GUI section: defaults -> cordis row config -> saved user section.
|
|
423
|
+
// (installSettingsSection inlined: no @deepseek-ai/dsh-settings import, so the
|
|
424
|
+
// profile does not need a second copy of that package.)
|
|
425
|
+
getConfig = () => config;
|
|
426
|
+
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
427
|
+
lastTestCacheRunnerCtx = ctx;
|
|
428
|
+
// Cache should not survive profile restarts (apply is called per reload).
|
|
429
|
+
// We deliberately do NOT clear on every apply — that would wipe badges when
|
|
430
|
+
// the user is just typing in the settings card. Re-init only on true reload.
|
|
431
|
+
ensureSandboxRunner(ctx);
|
|
432
|
+
|
|
433
|
+
// Self-healing idle cooldowns: every 60s, lift expired cooldowns for keys
|
|
434
|
+
// that have been idle for selfHealIdleMs (default 1h). ponytail: small
|
|
435
|
+
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
436
|
+
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
437
|
+
// fresh config on every tick. Naive but correct: 60s cadence is cheap.
|
|
438
|
+
// #196: canary probing before key release from cooldown.
|
|
439
|
+
// Every canaryIntervalMs, probe refs that are in cooldown and close to expiry.
|
|
440
|
+
// Canary prober lifecycle effect
|
|
441
|
+
ctx.effect(() => {
|
|
442
|
+
const cfg = getConfig();
|
|
443
|
+
if (!cfg || !cfg.canaryProbingEnabled) return () => {};
|
|
444
|
+
const timer = setInterval(() => {
|
|
445
|
+
try {
|
|
446
|
+
const c = getConfig();
|
|
447
|
+
if (!c || !c.canaryProbingEnabled) return;
|
|
448
|
+
const runner = ensureSandboxRunner(ctx);
|
|
449
|
+
if (!runner) return;
|
|
450
|
+
if (!canaryProber) {
|
|
451
|
+
canaryProber = new CanaryProber({ sandboxRunner: runner, intervalMs: c.canaryIntervalMs });
|
|
452
|
+
}
|
|
453
|
+
const providers = Array.isArray(c.providers) ? c.providers : [];
|
|
454
|
+
for (const p of providers) {
|
|
455
|
+
const pool = buildRuntime().providerToPool.get(p.provider);
|
|
456
|
+
if (!pool) continue;
|
|
457
|
+
for (const ref of pool.refs) {
|
|
458
|
+
const until = pool.state.failedUntil.get(ref) ?? 0;
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
if (until > now && until - now < (c.canaryIntervalMs ?? 30000)) {
|
|
461
|
+
canaryProber.probe(ref, ref);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch (_) { /* ponytail: never crash the timer */ }
|
|
466
|
+
}, cfg.canaryIntervalMs ?? 30000);
|
|
467
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
468
|
+
return () => clearInterval(timer);
|
|
469
|
+
}, 'dsh-key-rotation: canary prober');
|
|
470
|
+
|
|
471
|
+
// Self-healing idle cooldowns lifecycle effect
|
|
472
|
+
ctx.effect(() => {
|
|
473
|
+
const cfg = getConfig();
|
|
474
|
+
if (!cfg || cfg.selfHealCooldown === false) return () => {};
|
|
475
|
+
const timer = setInterval(() => {
|
|
476
|
+
try {
|
|
477
|
+
const c = getConfig();
|
|
478
|
+
if (!c || c.selfHealCooldown === false) return;
|
|
479
|
+
const idle = Number.isFinite(c.selfHealIdleMs) && c.selfHealIdleMs > 0 ? c.selfHealIdleMs : 3600000;
|
|
480
|
+
const providers = Array.isArray(c.providers) ? c.providers : [];
|
|
481
|
+
const pools = providers
|
|
482
|
+
.map((p) => buildRuntime().providerToPool.get(p.provider))
|
|
483
|
+
.filter(Boolean);
|
|
484
|
+
healIdleCooldowns(pools, idle);
|
|
485
|
+
} catch (_) { /* ponytail: never crash the timer */ }
|
|
486
|
+
}, 60000);
|
|
487
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
488
|
+
return () => clearInterval(timer);
|
|
489
|
+
}, 'dsh-key-rotation: self-healing idle');
|
|
490
|
+
|
|
491
|
+
// Dashboard widget now lives in client.js (mountDashboard, see issue #152).
|
|
492
|
+
const DASH_HTML = '';
|
|
493
|
+
// ── key-pool state, persisted across config reloads ──
|
|
494
|
+
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
495
|
+
const poolState = new Map();
|
|
496
|
+
// Periodic backup of pools config
|
|
497
|
+
ctx.effect(() => {
|
|
498
|
+
const { backupDir, backupIntervalMs, backupKeep } = buildRuntime();
|
|
499
|
+
if (!backupDir) return;
|
|
500
|
+
const id = setInterval(() => {
|
|
501
|
+
try {
|
|
502
|
+
const fs = require('node:fs');
|
|
503
|
+
const path = require('node:path');
|
|
504
|
+
const dir = backupDir;
|
|
505
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
506
|
+
const now = new Date();
|
|
507
|
+
const dateStr = now.toISOString().slice(0,10).replace(/-/g,'');
|
|
508
|
+
const file = path.join(dir, 'pools-' + dateStr + '.json');
|
|
509
|
+
const data = JSON.stringify({ backup: now.toISOString(), providers: getConfig()?.providers ?? [] }, null, 2);
|
|
510
|
+
fs.writeFileSync(file, data, 'utf8');
|
|
511
|
+
// prune old backups
|
|
512
|
+
const keep = backupKeep || 7;
|
|
513
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith('pools-') && f.endsWith('.json')).sort();
|
|
514
|
+
while (files.length > keep) {
|
|
515
|
+
const old = files.shift();
|
|
516
|
+
fs.unlinkSync(path.join(dir, old));
|
|
517
|
+
}
|
|
518
|
+
} catch (e) {
|
|
519
|
+
console.warn('[dsh-key-rotation] backup failed:', String(e?.message ?? e));
|
|
520
|
+
}
|
|
521
|
+
}, backupIntervalMs || 86400000);
|
|
522
|
+
return () => clearInterval(id);
|
|
523
|
+
}, 'dsh-key-rotation: backup pools');
|
|
524
|
+
// Periodic save of usage/cost stats to file
|
|
525
|
+
ctx.effect(() => {
|
|
526
|
+
const { backupDir } = buildRuntime();
|
|
527
|
+
if (!backupDir) return;
|
|
528
|
+
try {
|
|
529
|
+
const fs = require('node:fs');
|
|
530
|
+
const path = require('node:path');
|
|
531
|
+
const statsFile = path.join(backupDir, 'stats.json');
|
|
532
|
+
// Load existing stats at startup
|
|
533
|
+
try {
|
|
534
|
+
if (fs.existsSync(statsFile)) {
|
|
535
|
+
const saved = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
536
|
+
for (const st of poolState.values()) {
|
|
537
|
+
if (saved.usageCounts && st.usageCounts) { for (const [k, v] of Object.entries(saved.usageCounts)) st.usageCounts.set(k, (st.usageCounts.get(k) ?? 0) + v); }
|
|
538
|
+
if (saved.costPerKey && st.costPerKey) { for (const [k, v] of Object.entries(saved.costPerKey)) st.costPerKey.set(k, (st.costPerKey.get(k) ?? 0) + v); }
|
|
539
|
+
if (saved.lastUsedAt && st.lastUsedAt) { for (const [k, v] of Object.entries(saved.lastUsedAt)) { if (!st.lastUsedAt.has(k) || v > st.lastUsedAt.get(k)) st.lastUsedAt.set(k, v); } }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch {}
|
|
543
|
+
// Periodic save
|
|
544
|
+
const id = setInterval(() => {
|
|
545
|
+
try {
|
|
546
|
+
const usageCounts = {}; const costPerKey = {}; const lastUsedAt = {};
|
|
547
|
+
for (const [base, st] of poolState) {
|
|
548
|
+
if (st.usageCounts) for (const [k, v] of st.usageCounts) usageCounts[k] = v;
|
|
549
|
+
if (st.costPerKey) for (const [k, v] of st.costPerKey) costPerKey[k] = v;
|
|
550
|
+
if (st.lastUsedAt) for (const [k, v] of st.lastUsedAt) lastUsedAt[k] = v;
|
|
551
|
+
}
|
|
552
|
+
fs.writeFileSync(statsFile, JSON.stringify({ t: Date.now(), usageCounts, costPerKey, lastUsedAt }), 'utf8');
|
|
553
|
+
} catch {}
|
|
554
|
+
}, 60000);
|
|
555
|
+
return () => clearInterval(id);
|
|
556
|
+
} catch { return () => {}; }
|
|
557
|
+
}, 'dsh-key-rotation: persist stats');
|
|
558
|
+
// Rotation schedule: shift pointer every N days
|
|
559
|
+
ctx.effect(() => {
|
|
560
|
+
const { rotationScheduleDays } = buildRuntime();
|
|
561
|
+
if (!rotationScheduleDays || rotationScheduleDays <= 0) return;
|
|
562
|
+
const intervalMs = Math.min(rotationScheduleDays * 86400000, 2147483647);
|
|
563
|
+
const id = setInterval(() => {
|
|
564
|
+
try {
|
|
565
|
+
const rt = buildRuntime();
|
|
566
|
+
let shifted = 0;
|
|
567
|
+
for (const pool of rt.poolByRef.values()) {
|
|
568
|
+
if (pool.refs.length < 2) continue;
|
|
569
|
+
const oldPtr = pool.state.pointer ?? 0;
|
|
570
|
+
pool.state.pointer = (oldPtr + 1) % pool.refs.length;
|
|
571
|
+
shifted++;
|
|
572
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: scheduled rotation -> ${pool.refs[pool.state.pointer]} (day ${rotationScheduleDays})`);
|
|
573
|
+
}
|
|
574
|
+
if (shifted) console.warn(`[dsh-key-rotation] schedule: rotated ${shifted} pools`);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
console.warn('[dsh-key-rotation] schedule error:', String(e?.message ?? e));
|
|
577
|
+
}
|
|
578
|
+
}, intervalMs);
|
|
579
|
+
return () => clearInterval(id);
|
|
580
|
+
}, 'dsh-key-rotation: rotation schedule');
|
|
581
|
+
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
582
|
+
ctx.effect(() => {
|
|
583
|
+
const id = setInterval(() => {
|
|
584
|
+
const now = Date.now();
|
|
585
|
+
// probe events for keys whose cooldown just expired
|
|
586
|
+
for (const st of poolState.values()) {
|
|
587
|
+
for (const [ref, until] of [...(st.failedUntil?.entries() ?? [])]) {
|
|
588
|
+
if (until <= now && !st.probedAt?.has(ref)) {
|
|
589
|
+
st.events.push({ at: until, ref, reason: 'probe', cooldownMs: 0, type: 'probe' });
|
|
590
|
+
if (st.events.length > 50) st.events.shift();
|
|
591
|
+
if (!st.probedAt) st.probedAt = new Map();
|
|
592
|
+
st.probedAt.set(ref, until);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const n = sweepExpired(poolState, now);
|
|
597
|
+
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
598
|
+
// #207 expiry pre-warning + #208 cost budget - piggybacked on this timer,
|
|
599
|
+
// deduped to one notification per key/window per day (shouldNotifyDaily).
|
|
600
|
+
try {
|
|
601
|
+
const runtime = buildRuntime();
|
|
602
|
+
const seen = new Set();
|
|
603
|
+
for (const pool of runtime.poolByRef.values()) {
|
|
604
|
+
if (seen.has(pool.base)) continue;
|
|
605
|
+
seen.add(pool.base);
|
|
606
|
+
// #207: keys expiring within expiryWarnDays -> one webhook per key/day
|
|
607
|
+
for (const { ref, expiresInDays } of expiringSoon(pool, runtime.expiryWarnDays, now)) {
|
|
608
|
+
if (!shouldNotifyDaily(expiryNotifiedAt, pool.base + ':' + ref, now)) continue;
|
|
609
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: key ${ref} expires in ~${expiresInDays}d`);
|
|
610
|
+
if (runtime.notifyWebhook) {
|
|
611
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
612
|
+
title: `Key expiring soon: ${pool.base}`,
|
|
613
|
+
text: `${ref} expires in ~${expiresInDays} day(s)`,
|
|
614
|
+
provider: pool.base,
|
|
615
|
+
kind: 'expiry',
|
|
616
|
+
keys: [ref],
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
|
|
621
|
+
const budget = runtime.providerBudgets.get(pool.base);
|
|
622
|
+
if (!budget) continue;
|
|
623
|
+
const daily = costForDay(pool.state.costDays);
|
|
624
|
+
const weekly = costForWeek(pool.state.costDays, now);
|
|
625
|
+
const verdict = budgetVerdict(daily, budget.costBudgetDaily);
|
|
626
|
+
const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
|
|
627
|
+
const hit = verdict.warn || wVerdict.warn;
|
|
628
|
+
if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
|
|
629
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
|
|
630
|
+
if (runtime.notifyWebhook) {
|
|
631
|
+
// #217: budget webhook gains action buttons when a callback token
|
|
632
|
+
// is configured (the /webhook-action route already knows these ids)
|
|
633
|
+
const token = runtime.webhookActionToken ?? '';
|
|
634
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
635
|
+
title: `Cost budget: ${pool.base}`,
|
|
636
|
+
text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
|
|
637
|
+
provider: pool.base,
|
|
638
|
+
kind: 'budget',
|
|
639
|
+
spend: { daily, weekly },
|
|
640
|
+
actionToken: token || undefined,
|
|
641
|
+
actions: token ? [
|
|
642
|
+
{ id: `pause-${pool.base}`, label: 'Pause 1h' },
|
|
643
|
+
{ id: `reset-${pool.base}`, label: 'Reset cooldown' },
|
|
644
|
+
] : undefined,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
|
|
649
|
+
const until = now + DAY_MS;
|
|
650
|
+
for (const ref of pool.refs) {
|
|
651
|
+
if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// #221: pool running low - webhook while healthy < warnBelowHealthy
|
|
655
|
+
const warnBelow = runtime.warnBelowHealthy ?? 0;
|
|
656
|
+
if (warnBelow > 0) {
|
|
657
|
+
let healthy = 0;
|
|
658
|
+
for (const ref of pool.refs) {
|
|
659
|
+
const fu = pool.state.failedUntil.get(ref);
|
|
660
|
+
if (fu !== undefined && fu > now) continue;
|
|
661
|
+
const exp = pool.expiresAt?.[ref];
|
|
662
|
+
if (exp !== undefined && now >= exp) continue;
|
|
663
|
+
healthy++;
|
|
664
|
+
}
|
|
665
|
+
if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
|
|
666
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
|
|
667
|
+
if (runtime.notifyWebhook) {
|
|
668
|
+
const token = runtime.webhookActionToken ?? '';
|
|
669
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
670
|
+
title: `Pool running low: ${pool.base}`,
|
|
671
|
+
text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
|
|
672
|
+
provider: pool.base,
|
|
673
|
+
kind: 'low-health',
|
|
674
|
+
healthy,
|
|
675
|
+
total: pool.refs.length,
|
|
676
|
+
actionToken: token || undefined,
|
|
677
|
+
actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// #225: latency SLO - webhook when a key's p95 exceeds the threshold
|
|
683
|
+
const slo = runtime.latencySloMs ?? 0;
|
|
684
|
+
if (slo > 0) {
|
|
685
|
+
for (const ref of pool.refs) {
|
|
686
|
+
const snap = latencyHistogram.snapshot(ref);
|
|
687
|
+
if (!snap.p95 || snap.p95 <= slo) continue;
|
|
688
|
+
if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
|
|
689
|
+
console.warn(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
|
|
690
|
+
if (runtime.notifyWebhook) {
|
|
691
|
+
webhookSender.send(runtime.notifyWebhook, {
|
|
692
|
+
title: `Latency SLO exceeded: ${pool.base}`,
|
|
693
|
+
text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
|
|
694
|
+
provider: pool.base,
|
|
695
|
+
kind: 'latency-slo',
|
|
696
|
+
ref,
|
|
697
|
+
p95: Math.round(snap.p95),
|
|
698
|
+
slo,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} catch (_) { /* maintenance must never crash the sweep */ }
|
|
705
|
+
}, 30000);
|
|
706
|
+
return () => clearInterval(id);
|
|
707
|
+
}, 'dsh-key-rotation: sweep expired cooldowns');
|
|
708
|
+
|
|
709
|
+
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
710
|
+
function buildRuntime() {
|
|
711
|
+
// Deep-clone before resolving: the frozen snapshot from settings.register
|
|
712
|
+
// must never be written to by schemastery's dict resolver.
|
|
713
|
+
const cfg = Config(structuredClone(getConfig() ?? {})) ?? {};
|
|
714
|
+
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
715
|
+
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
716
|
+
const maxCooldownMs = cfg.maxCooldownMs ?? undefined;
|
|
717
|
+
const notifyWebhook = cfg.notifyWebhook ?? '';
|
|
718
|
+
const notifyThreshold = cfg.notifyThreshold ?? 3;
|
|
719
|
+
const backupDir = cfg.backupDir ?? '';
|
|
720
|
+
const backupIntervalMs = cfg.backupIntervalMs ?? 86400000;
|
|
721
|
+
const backupKeep = cfg.backupKeep ?? 7;
|
|
722
|
+
const rotationScheduleDays = cfg.rotationScheduleDays ?? 0;
|
|
723
|
+
const rateLimitThreshold = cfg.rateLimitThreshold ?? 0.1;
|
|
724
|
+
const rpmLimit = cfg.rpmLimit ?? 0;
|
|
725
|
+
const webhookActionToken = cfg.webhookActionToken ?? '';
|
|
726
|
+
const incidentThreshold = cfg.incidentThreshold ?? 5;
|
|
727
|
+
const concurrencyLimit = cfg.concurrencyLimit ?? 0;
|
|
728
|
+
const canaryProbingEnabled = cfg.canaryProbingEnabled ?? false;
|
|
729
|
+
const canaryIntervalMs = cfg.canaryIntervalMs ?? 30000;
|
|
730
|
+
const cascade = Array.isArray(cfg.cascade) ? cfg.cascade : [];
|
|
731
|
+
const quotaResetWindow = cfg.quotaResetWindow || null;
|
|
732
|
+
|
|
733
|
+
// ref -> pool (every key env of every configured provider)
|
|
734
|
+
const poolByRef = new Map();
|
|
735
|
+
// provider route (from llm-pi-ai profiles) -> its key pool
|
|
736
|
+
const providerToPool = new Map();
|
|
737
|
+
// per-model key pools: provider -> Map<model, pool>
|
|
738
|
+
const modelPoolByProvider = new Map();
|
|
739
|
+
// clone route ids (for the settings dropdown filter)
|
|
740
|
+
const cloneIds = new Set();
|
|
741
|
+
|
|
742
|
+
const makeState = (base) => {
|
|
743
|
+
let st = poolState.get(base);
|
|
744
|
+
if (!st) {
|
|
745
|
+
st = {
|
|
746
|
+
failedUntil: new Map(),
|
|
747
|
+
failCounts: new Map(),
|
|
748
|
+
authFailCounts: new Map(),
|
|
749
|
+
brokenUntil: new Map(),
|
|
750
|
+
costPerKey: new Map(),
|
|
751
|
+
lastUsedAt: new Map(),
|
|
752
|
+
usageCounts: new Map(),
|
|
753
|
+
byModel: new Map(),
|
|
754
|
+
usageDays: new Map(),
|
|
755
|
+
quotaWindows: new Map(),
|
|
756
|
+
pointer: 0,
|
|
757
|
+
lastUsed: undefined,
|
|
758
|
+
switches: 0,
|
|
759
|
+
lastReason: undefined,
|
|
760
|
+
lastSwitchAt: undefined,
|
|
761
|
+
lastExhaustionAt: undefined,
|
|
762
|
+
exhaustionCount: 0,
|
|
763
|
+
events: [],
|
|
764
|
+
};
|
|
765
|
+
poolState.set(base, st);
|
|
766
|
+
}
|
|
767
|
+
return st;
|
|
768
|
+
};
|
|
769
|
+
const parseExpiry = (v) => {
|
|
770
|
+
if (typeof v === 'number' && v > 0) return v;
|
|
771
|
+
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
772
|
+
return undefined;
|
|
773
|
+
};
|
|
774
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
775
|
+
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
776
|
+
if (refs.length === 0) return null;
|
|
777
|
+
const w = Array.isArray(weights) ? weights : [];
|
|
778
|
+
const weightedRefs = [];
|
|
779
|
+
for (let i = 0; i < refs.length; i++) {
|
|
780
|
+
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
781
|
+
for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
|
|
782
|
+
}
|
|
783
|
+
const parsedExpiry = {};
|
|
784
|
+
if (Array.isArray(expiresAt)) {
|
|
785
|
+
for (let i = 0; i < refs.length; i++) {
|
|
786
|
+
const exp = parseExpiry(expiresAt[i]);
|
|
787
|
+
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
791
|
+
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
792
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
|
|
793
|
+
};
|
|
794
|
+
for (const p of cfg.providers ?? []) {
|
|
795
|
+
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
796
|
+
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
797
|
+
// base provider pool (fallback)
|
|
798
|
+
const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax);
|
|
799
|
+
if (pool) {
|
|
800
|
+
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
801
|
+
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
802
|
+
}
|
|
803
|
+
// per-model pools
|
|
804
|
+
const models = p.models ?? {};
|
|
805
|
+
const byModel = new Map();
|
|
806
|
+
for (const [model, mp] of Object.entries(models)) {
|
|
807
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax);
|
|
808
|
+
if (mpool) {
|
|
809
|
+
byModel.set(model, mpool);
|
|
810
|
+
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (byModel.size > 0) modelPoolByProvider.set(p.provider, byModel);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let profiles = {};
|
|
817
|
+
try {
|
|
818
|
+
profiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? {};
|
|
819
|
+
} catch {
|
|
820
|
+
/* settings not mounted yet — empty mapping */
|
|
821
|
+
}
|
|
822
|
+
for (const [provider, profile] of Object.entries(profiles)) {
|
|
823
|
+
if (profile?.apiKeyEnv && poolByRef.has(profile.apiKeyEnv)) {
|
|
824
|
+
providerToPool.set(provider, poolByRef.get(profile.apiKeyEnv));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// auto-cleanup: remove poolState for providers that are now empty or removed
|
|
829
|
+
for (const key of [...poolState.keys()]) {
|
|
830
|
+
if (![...poolByRef.values()].some((p) => p.base === key)) poolState.delete(key);
|
|
831
|
+
}
|
|
832
|
+
// #192: drop RPM windows for refs that no longer belong to any pool
|
|
833
|
+
for (const st of poolState.values()) {
|
|
834
|
+
if (st.rpmWindows) bucketSweep(st.rpmWindows, new Set(poolByRef.keys()));
|
|
835
|
+
}
|
|
836
|
+
// #195: provider -> tags (metadata, surfaced in status)
|
|
837
|
+
const providerTags = new Map();
|
|
838
|
+
// #208: provider -> { costBudgetDaily, costBudgetWeekly, pauseOnBudget }
|
|
839
|
+
const providerBudgets = new Map();
|
|
840
|
+
for (const p of cfg.providers ?? []) {
|
|
841
|
+
if (Array.isArray(p.tags) && p.tags.length > 0) providerTags.set(p.provider, p.tags);
|
|
842
|
+
const daily = typeof p.costBudgetDaily === 'number' ? p.costBudgetDaily : 0;
|
|
843
|
+
const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
|
|
844
|
+
if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
|
|
845
|
+
}
|
|
846
|
+
return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
850
|
+
// Round-robin over the pool, skipping keys in cooldown; the request's
|
|
851
|
+
// provider identity never changes, so pi-ai replay state stays consistent.
|
|
852
|
+
const credentials = ctx.get('credentials');
|
|
853
|
+
if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
|
|
854
|
+
const original = credentials.resolve.bind(credentials);
|
|
855
|
+
// Kept for the status route: it must ask about one exact ref instead of
|
|
856
|
+
// being rotated to a different key by the patch below.
|
|
857
|
+
credentials.__dshKeyRotationOriginalResolve = original;
|
|
858
|
+
credentials.resolve = async (ref) => {
|
|
859
|
+
const { poolByRef } = buildRuntime();
|
|
860
|
+
const pool = poolByRef.get(ref);
|
|
861
|
+
if (!pool) return original(ref);
|
|
862
|
+
const now = Date.now();
|
|
863
|
+
const list = pool.weightedRefs ?? pool.refs;
|
|
864
|
+
const start = pool.state.pointer ?? 0;
|
|
865
|
+
for (let i = 0; i < list.length; i++) {
|
|
866
|
+
const index = (start + i) % list.length;
|
|
867
|
+
const candidate = list[index];
|
|
868
|
+
const until = pool.state.failedUntil.get(candidate);
|
|
869
|
+
if (until !== undefined && until > now) continue;
|
|
870
|
+
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
871
|
+
// #192 RPM token bucket: skip a key that already hit its requests/min cap
|
|
872
|
+
const rpmLimit = pool.rpmLimit ?? 0;
|
|
873
|
+
if (rpmLimit > 0) {
|
|
874
|
+
if (!pool.state.rpmWindows) pool.state.rpmWindows = new Map();
|
|
875
|
+
if (!bucketAllow(pool.state.rpmWindows, candidate, rpmLimit, now)) {
|
|
876
|
+
const waitMs = bucketRetryMs(pool.state.rpmWindows, candidate, rpmLimit, now);
|
|
877
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < now + waitMs) {
|
|
878
|
+
pool.state.failedUntil.set(candidate, now + waitMs);
|
|
879
|
+
}
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (pool.perHour) {
|
|
884
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
885
|
+
let win = pool.state.quotaWindows.get(candidate);
|
|
886
|
+
if (!win || now - win.start >= 3600000) win = { count: 0, start: now };
|
|
887
|
+
if (win.count >= pool.perHour) {
|
|
888
|
+
const until = win.start + 3600000;
|
|
889
|
+
if ((pool.state.failedUntil.get(candidate) ?? 0) < until) pool.state.failedUntil.set(candidate, until);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
let hit = await original(candidate);
|
|
894
|
+
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
895
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
896
|
+
pool.state.lastUsed = candidate;
|
|
897
|
+
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
898
|
+
pool.state.failedUntil.delete(candidate);
|
|
899
|
+
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
900
|
+
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
901
|
+
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
902
|
+
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
903
|
+
if (pool.perHour) {
|
|
904
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
905
|
+
let win2 = pool.state.quotaWindows.get(candidate);
|
|
906
|
+
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
907
|
+
win2.count++;
|
|
908
|
+
pool.state.quotaWindows.set(candidate, win2);
|
|
909
|
+
}
|
|
910
|
+
return hit;
|
|
911
|
+
}
|
|
912
|
+
// fallback: env var (transient, not persisted)
|
|
913
|
+
const envVal = envValue(candidate);
|
|
914
|
+
if (envVal !== undefined) {
|
|
915
|
+
pool.state.pointer = (index + 1) % list.length;
|
|
916
|
+
pool.state.lastUsed = candidate;
|
|
917
|
+
if (pool.state.failCounts) pool.state.failCounts.delete(candidate);
|
|
918
|
+
pool.state.failedUntil.delete(candidate);
|
|
919
|
+
if (pool.state.authFailCounts) pool.state.authFailCounts.delete(candidate);
|
|
920
|
+
if (pool.state.brokenUntil) pool.state.brokenUntil.delete(candidate);
|
|
921
|
+
if (!pool.state.usageCounts) pool.state.usageCounts = new Map();
|
|
922
|
+
pool.state.usageCounts.set(candidate, (pool.state.usageCounts.get(candidate) ?? 0) + 1);
|
|
923
|
+
if (pool.perHour) {
|
|
924
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
925
|
+
let win2 = pool.state.quotaWindows.get(candidate);
|
|
926
|
+
if (!win2 || now - win2.start >= 3600000) win2 = { count: 0, start: now };
|
|
927
|
+
win2.count++;
|
|
928
|
+
pool.state.quotaWindows.set(candidate, win2);
|
|
929
|
+
}
|
|
930
|
+
return { value: envVal, source: 'env' };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return original(ref); // everything cooled/missing — surface the base value
|
|
934
|
+
};
|
|
935
|
+
credentials.__dshKeyRotationPatched = true;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const finishError = (code, message) => ({
|
|
939
|
+
type: 'finish',
|
|
940
|
+
reason: { kind: 'error', failure: Object.freeze({ code, message }) },
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// Latency recording (#6): record successful llm/stream latency per ref.
|
|
944
|
+
// ponytail: only the true success path (finish-chunk). Failures are not recorded.
|
|
945
|
+
let _rotateStartMs = Date.now();
|
|
946
|
+
function recordLatency(pool) {
|
|
947
|
+
try {
|
|
948
|
+
const cfg = getConfig();
|
|
949
|
+
if (!cfg || cfg.latencyEnabled === false) return;
|
|
950
|
+
const ref = pool && pool.state && pool.state.lastUsed;
|
|
951
|
+
if (!ref) return;
|
|
952
|
+
const elapsed = Date.now() - _rotateStartMs;
|
|
953
|
+
if (!Number.isFinite(elapsed) || elapsed < 0) return;
|
|
954
|
+
latencyHistogram.record(ref, elapsed);
|
|
955
|
+
} catch (_) { /* ponytail: never crash */ }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Retry one request on the next pool key when the current key fails with a
|
|
959
|
+
// switchable error before any content chunk. The provider never changes —
|
|
960
|
+
// the resolve patch hands out the next key on each dispatch.
|
|
961
|
+
function rotate(options, pool) {
|
|
962
|
+
return (async function* () {
|
|
963
|
+
const { switchCodes, cooldownMs, maxCooldownMs } = buildRuntime();
|
|
964
|
+
let lastFailure = null;
|
|
965
|
+
_rotateStartMs = Date.now();
|
|
966
|
+
|
|
967
|
+
const runtime0 = buildRuntime();
|
|
968
|
+
if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
969
|
+
// #193: prefer least-loaded key within limit
|
|
970
|
+
const available = (pool.weightedRefs ?? pool.refs).filter((r) => {
|
|
971
|
+
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
972
|
+
if (fu > Date.now()) return false;
|
|
973
|
+
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
974
|
+
if (exp !== undefined && Date.now() >= exp) return false;
|
|
975
|
+
return true;
|
|
976
|
+
});
|
|
977
|
+
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
978
|
+
if (preferred && (pool.weightedRefs ?? pool.refs)[0] !== preferred) {
|
|
979
|
+
// Move preferred to front of the attempt list
|
|
980
|
+
const list = (pool.weightedRefs ?? pool.refs).slice();
|
|
981
|
+
const i = list.indexOf(preferred);
|
|
982
|
+
if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
|
|
983
|
+
pool.weightedRefs = list;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
for (let attempt = 0; attempt < (pool.weightedRefs ?? pool.refs).length; attempt++) {
|
|
987
|
+
let yielded = false;
|
|
988
|
+
let switching = false;
|
|
989
|
+
let inner;
|
|
990
|
+
try {
|
|
991
|
+
// mark the internal dispatch so the interceptor does not re-rotate
|
|
992
|
+
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
993
|
+
} catch (e) {
|
|
994
|
+
if (pool.state.lastUsed) { const _retry = parseRetryAfter(String(e?.message ?? '')); const _base = pool.cooldownMs ?? cooldownMs; const _max = pool.maxCooldownMs ?? maxCooldownMs; const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base; const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max); pushEvent(pool, pool.state.lastUsed, e?.code ?? 'TRANSPORT', _b); const _code = String(e?.code ?? ''); if (_code === 'AUTH' || /auth/i.test(String(e?.message ?? ''))) { const _c = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1; pool.state.authFailCounts.set(pool.state.lastUsed, _c); if (_c >= 3) { pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30); } } else { pool.state.authFailCounts.delete(pool.state.lastUsed); } }
|
|
995
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
996
|
+
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
997
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const _pickedRef = pool.state.lastUsed;
|
|
1002
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
|
|
1003
|
+
try {
|
|
1004
|
+
for await (const chunk of inner) {
|
|
1005
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
1006
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
1007
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
1008
|
+
yielded = true;
|
|
1009
|
+
yield chunk;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (chunk && chunk.type === 'finish') {
|
|
1013
|
+
const kind = chunk.reason?.kind;
|
|
1014
|
+
const failure = chunk.reason?.failure;
|
|
1015
|
+
const code = failure?.code;
|
|
1016
|
+
const message = failure?.message ?? '';
|
|
1017
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1018
|
+
const switchable = !yielded && kind === 'error' &&
|
|
1019
|
+
(effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
1020
|
+
if (switchable) {
|
|
1021
|
+
if (pool.state.lastUsed) {
|
|
1022
|
+
const _retry = parseRetryAfter(message);
|
|
1023
|
+
const _base = pool.cooldownMs ?? cooldownMs;
|
|
1024
|
+
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
1025
|
+
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
1026
|
+
const _b = recordFailure(pool, pool.state.lastUsed, Date.now(), _effBase, _max);
|
|
1027
|
+
pushEvent(pool, pool.state.lastUsed, code ?? 'UNKNOWN', _b);
|
|
1028
|
+
// authFailCounts/brokenUntil: lazy-init if state was created by an older plugin version
|
|
1029
|
+
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
1030
|
+
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
1031
|
+
const _code2 = String(code ?? '');
|
|
1032
|
+
if (_code2 === 'AUTH' || /auth/i.test(message)) {
|
|
1033
|
+
const _c2 = (pool.state.authFailCounts.get(pool.state.lastUsed) ?? 0) + 1;
|
|
1034
|
+
pool.state.authFailCounts.set(pool.state.lastUsed, _c2);
|
|
1035
|
+
if (_c2 >= 3) {
|
|
1036
|
+
pool.state.brokenUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
|
|
1037
|
+
pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + 86400000*30);
|
|
1038
|
+
}
|
|
1039
|
+
} else {
|
|
1040
|
+
pool.state.authFailCounts.delete(pool.state.lastUsed);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1044
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
1045
|
+
pool.state.lastSwitchAt = Date.now();
|
|
1046
|
+
lastFailure = chunk;
|
|
1047
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
1048
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
1049
|
+
if (buildRuntime().switchNotify && pool.state.lastUsed) {
|
|
1050
|
+
notifySwitch(buildRuntime(), pool, {
|
|
1051
|
+
provider: options.provider,
|
|
1052
|
+
from: pool.state.lastUsed,
|
|
1053
|
+
code: String(code ?? 'UNKNOWN'),
|
|
1054
|
+
at: pool.state.lastSwitchAt,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
switching = true;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
// cost tracking if provider returns usage.cost
|
|
1061
|
+
if (chunk.usage?.cost != null && pool.state.lastUsed) {
|
|
1062
|
+
const c = Number(chunk.usage.cost);
|
|
1063
|
+
if (!isNaN(c)) {
|
|
1064
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
1065
|
+
pool.state.costPerKey.set(pool.state.lastUsed, (pool.state.costPerKey.get(pool.state.lastUsed) ?? 0) + c);
|
|
1066
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
1067
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
1068
|
+
const cday = new Date().toISOString().slice(0, 10);
|
|
1069
|
+
const cMap = pool.state.costDays.get(pool.state.lastUsed) || new Map();
|
|
1070
|
+
cMap.set(cday, (cMap.get(cday) ?? 0) + c);
|
|
1071
|
+
pool.state.costDays.set(pool.state.lastUsed, cMap);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Usage by day (#119)
|
|
1075
|
+
if (pool.state.lastUsed) {
|
|
1076
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
1077
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
1078
|
+
const dayMap = pool.state.usageDays.get(pool.state.lastUsed) || new Map();
|
|
1079
|
+
dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
|
|
1080
|
+
pool.state.usageDays.set(pool.state.lastUsed, dayMap);
|
|
1081
|
+
}
|
|
1082
|
+
// Per-model request detail (#121)
|
|
1083
|
+
if (pool.state.lastUsed && options.model) {
|
|
1084
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
1085
|
+
let byRef = pool.state.byModel.get(pool.state.lastUsed);
|
|
1086
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(pool.state.lastUsed, byRef); }
|
|
1087
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
1088
|
+
}
|
|
1089
|
+
// Proactive rate-limit (#115): if response headers say this key is near
|
|
1090
|
+
// its quota, cool it down so the NEXT request starts on a different key.
|
|
1091
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
1092
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
1093
|
+
if (rate && pool.state.lastUsed) {
|
|
1094
|
+
const { rateLimitThreshold } = buildRuntime();
|
|
1095
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
1096
|
+
const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
|
|
1097
|
+
recordFailure(pool, pool.state.lastUsed, Date.now(), cool, pool.maxCooldownMs);
|
|
1098
|
+
pushEvent(pool, pool.state.lastUsed, 'RATE_LIMIT', cool);
|
|
1099
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${pool.state.lastUsed} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
1103
|
+
if (rate && pool.state.lastUsed && Number.isFinite(rate.remaining)) {
|
|
1104
|
+
quotaStore.set(pool.state.lastUsed, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
|
|
1105
|
+
}
|
|
1106
|
+
yield chunk;
|
|
1107
|
+
recordLatency(pool);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
yield chunk;
|
|
1111
|
+
}
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
1114
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
1119
|
+
if (switching) continue; // try the next key
|
|
1120
|
+
return; // clean end — served
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// pool exhausted — all keys cooling or missing
|
|
1124
|
+
pool.state.lastExhaustionAt = Date.now();
|
|
1125
|
+
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
1126
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
1127
|
+
// notify via extracted helper (see notifyExhaustion above)
|
|
1128
|
+
notifyExhaustion(buildRuntime(), pool, { provider: options.provider });
|
|
1129
|
+
|
|
1130
|
+
// #194: cross-provider cascade failover (guarded against infinite recursion)
|
|
1131
|
+
const runtime = buildRuntime();
|
|
1132
|
+
if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
1133
|
+
const pools = runtime.providerToPool;
|
|
1134
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
1135
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
1136
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
1137
|
+
pool.state.lastReason = 'CASCADE';
|
|
1138
|
+
pool.state.lastSwitchAt = Date.now();
|
|
1139
|
+
// Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
|
|
1140
|
+
const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
|
|
1141
|
+
for await (const chunk of innerCascade) {
|
|
1142
|
+
yield chunk;
|
|
1143
|
+
}
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
|
|
1149
|
+
})();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ── status route: what the settings card cannot know on its own ──
|
|
1153
|
+
//
|
|
1154
|
+
// Reports, per configured provider, which key is in use, which are cooling
|
|
1155
|
+
// down and until when, whether an env name resolves to a credential at all
|
|
1156
|
+
// (a typo is otherwise silent), and how often rotation has fired.
|
|
1157
|
+
//
|
|
1158
|
+
// Key VALUES never leave the host — only the boolean fact that one exists.
|
|
1159
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1160
|
+
kind: 'exact',
|
|
1161
|
+
path: STATUS_PATH,
|
|
1162
|
+
handler: async (req, res) => {
|
|
1163
|
+
if (req.method !== 'GET') {
|
|
1164
|
+
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
1168
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const { poolByRef, providerTags, providerBudgets } = buildRuntime();
|
|
1172
|
+
const base = ctx.get('credentials');
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
const latencySloMs = buildRuntime().latencySloMs;
|
|
1175
|
+
const seen = new Set();
|
|
1176
|
+
const providers = [];
|
|
1177
|
+
for (const pool of poolByRef.values()) {
|
|
1178
|
+
if (seen.has(pool.base)) continue;
|
|
1179
|
+
seen.add(pool.base);
|
|
1180
|
+
try {
|
|
1181
|
+
const keys = [];
|
|
1182
|
+
for (const ref of pool.refs) {
|
|
1183
|
+
let present = false;
|
|
1184
|
+
let tail = '';
|
|
1185
|
+
let source = null;
|
|
1186
|
+
let writable = true;
|
|
1187
|
+
try {
|
|
1188
|
+
// The resolve patch is installed on this same service, so ask for
|
|
1189
|
+
// the exact ref: a pool ref would otherwise round-robin to another
|
|
1190
|
+
// key and report a missing name as present.
|
|
1191
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
1192
|
+
present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
1193
|
+
if (present) tail = keyTail(hit.value);
|
|
1194
|
+
// fallback: env var bootstrapping (issue #7)
|
|
1195
|
+
if (!present) {
|
|
1196
|
+
const ev = envValue(ref);
|
|
1197
|
+
if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
|
|
1198
|
+
}
|
|
1199
|
+
} catch {
|
|
1200
|
+
present = false;
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
const described = await base?.describe?.(ref);
|
|
1204
|
+
source = described?.source ?? null;
|
|
1205
|
+
writable = described?.writable !== false;
|
|
1206
|
+
} catch {
|
|
1207
|
+
/* describe is optional — the card falls back to editable */
|
|
1208
|
+
}
|
|
1209
|
+
const until = pool.state.failedUntil.get(ref);
|
|
1210
|
+
keys.push({
|
|
1211
|
+
ref,
|
|
1212
|
+
present,
|
|
1213
|
+
tail,
|
|
1214
|
+
source,
|
|
1215
|
+
writable,
|
|
1216
|
+
active: pool.state.lastUsed === ref,
|
|
1217
|
+
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
1218
|
+
// #210: RPM capacity snapshot (null when rpmLimit is off)
|
|
1219
|
+
rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
|
|
1220
|
+
// #215: effective round-robin weight of this key
|
|
1221
|
+
weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
|
|
1222
|
+
usage: pool.state.usageCounts?.get(ref) ?? 0,
|
|
1223
|
+
byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
|
|
1224
|
+
usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
|
|
1225
|
+
cost: pool.state.costPerKey?.get(ref) ?? 0,
|
|
1226
|
+
lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
|
|
1227
|
+
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
1228
|
+
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
1229
|
+
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
providers.push({
|
|
1233
|
+
provider: pool.base,
|
|
1234
|
+
keys,
|
|
1235
|
+
tags: providerTags.get(pool.base) ?? [],
|
|
1236
|
+
switches: pool.state.switches ?? 0,
|
|
1237
|
+
lastReason: pool.state.lastReason ?? null,
|
|
1238
|
+
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
1239
|
+
lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
|
|
1240
|
+
exhaustionCount: pool.state.exhaustionCount ?? 0,
|
|
1241
|
+
totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
|
|
1242
|
+
// #225: aggregate p95 across the pool's keys
|
|
1243
|
+
p95: (() => {
|
|
1244
|
+
const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
|
|
1245
|
+
return vals.length ? Math.round(Math.max(...vals)) : null;
|
|
1246
|
+
})(),
|
|
1247
|
+
latencySloMs,
|
|
1248
|
+
events: (pool.state.events ?? []).slice(-50),
|
|
1249
|
+
healthScore: computeHealthScore(pool.state),
|
|
1250
|
+
// #208: today/week spend + configured budget for the card
|
|
1251
|
+
todayCost: costForDay(pool.state.costDays),
|
|
1252
|
+
weeklyCost: costForWeek(pool.state.costDays, now),
|
|
1253
|
+
budgetDaily: providerBudgets.get(pool.base)?.costBudgetDaily ?? 0,
|
|
1254
|
+
budgetWeekly: providerBudgets.get(pool.base)?.costBudgetWeekly ?? 0,
|
|
1255
|
+
pauseOnBudget: providerBudgets.get(pool.base)?.pauseOnBudget ?? false,
|
|
1256
|
+
});
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
|
|
1259
|
+
providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
json(res, 200, { providers });
|
|
1263
|
+
},
|
|
1264
|
+
}), 'dsh-key-rotation: status route');
|
|
1265
|
+
|
|
1266
|
+
// #209: usage report - per-key requests/cost over the last N days.
|
|
1267
|
+
// ?format=csv returns text/csv; ?days=N window (1..90, default 7).
|
|
1268
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1269
|
+
kind: 'exact',
|
|
1270
|
+
path: USAGE_PATH,
|
|
1271
|
+
handler: (req, res) => {
|
|
1272
|
+
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
1273
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
|
|
1274
|
+
const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
|
|
1275
|
+
const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
|
|
1276
|
+
const csv = url.searchParams.get('format') === 'csv';
|
|
1277
|
+
const provider = url.searchParams.get('provider') ?? '';
|
|
1278
|
+
const runtime = buildRuntime();
|
|
1279
|
+
const now = Date.now();
|
|
1280
|
+
const seen = new Set();
|
|
1281
|
+
const report = [];
|
|
1282
|
+
for (const pool of runtime.poolByRef.values()) {
|
|
1283
|
+
if (seen.has(pool.base)) continue;
|
|
1284
|
+
seen.add(pool.base);
|
|
1285
|
+
if (provider && pool.base !== provider) continue;
|
|
1286
|
+
report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
|
|
1287
|
+
}
|
|
1288
|
+
if (csv) {
|
|
1289
|
+
res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
|
|
1290
|
+
const parts = [];
|
|
1291
|
+
for (const p of report) {
|
|
1292
|
+
if (parts.length > 0) parts.push('');
|
|
1293
|
+
parts.push('# ' + p.provider);
|
|
1294
|
+
parts.push(usageCsv(p.rows));
|
|
1295
|
+
}
|
|
1296
|
+
res.end(parts.join('\n') + '\n');
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
json(res, 200, { at: now, days, providers: report });
|
|
1300
|
+
},
|
|
1301
|
+
}), 'dsh-key-rotation: usage route');
|
|
1302
|
+
|
|
1303
|
+
// #218: full config snapshot - one JSON file to move between machines.
|
|
1304
|
+
// Secret values never travel: only credential/env names. Token fields are
|
|
1305
|
+
// exported as empty strings; on import they keep existing values when empty.
|
|
1306
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1307
|
+
kind: 'exact',
|
|
1308
|
+
path: SNAPSHOT_PATH,
|
|
1309
|
+
handler: async (req, res) => {
|
|
1310
|
+
if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
|
|
1311
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
|
|
1312
|
+
if (req.method === 'GET') {
|
|
1313
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
1314
|
+
const value = descriptor?.value ?? {};
|
|
1315
|
+
const exportable = { ...value };
|
|
1316
|
+
// token-shaped fields stay empty in the file; refs are names, not secrets
|
|
1317
|
+
exportable.webhookActionToken = '';
|
|
1318
|
+
if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
|
|
1319
|
+
json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
// POST = import: { snapshot } -> merge with current section, PUT semantics
|
|
1323
|
+
let body;
|
|
1324
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1325
|
+
const snap = body?.snapshot;
|
|
1326
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
|
|
1327
|
+
// #200 leak guard applies to imported content too
|
|
1328
|
+
try {
|
|
1329
|
+
const masked = structuredClone(snap);
|
|
1330
|
+
if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
|
|
1331
|
+
if (masked.webhookActionToken) masked.webhookActionToken = '***';
|
|
1332
|
+
if (masked.notifyWebhook) masked.notifyWebhook = '***';
|
|
1333
|
+
const findings = findSecrets(JSON.stringify(masked));
|
|
1334
|
+
if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
|
|
1335
|
+
} catch { /* scanning must never block a valid import */ }
|
|
1336
|
+
const settings = ctx.get('settings');
|
|
1337
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
1338
|
+
const desc = descriptorOf(ctx, NS);
|
|
1339
|
+
if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
|
|
1340
|
+
const cur = desc.value ?? {};
|
|
1341
|
+
// empty token fields in the file keep the current values (never wipe a secret)
|
|
1342
|
+
const merged = { ...cur, ...snap };
|
|
1343
|
+
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
1344
|
+
if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
|
|
1345
|
+
try {
|
|
1346
|
+
await settings.replace(NS, merged, desc.revision);
|
|
1347
|
+
const after = descriptorOf(ctx, NS);
|
|
1348
|
+
json(res, 200, { ok: true, revision: after?.revision });
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
|
|
1351
|
+
}
|
|
1352
|
+
},
|
|
1353
|
+
}), 'dsh-key-rotation: snapshot route');
|
|
1354
|
+
|
|
1355
|
+
// ── key route: store a key value without leaving the rotation card ──
|
|
1356
|
+
//
|
|
1357
|
+
// Adding a key used to mean two screens: create the credential elsewhere,
|
|
1358
|
+
// then type its env name here. The value is write-only from the browser —
|
|
1359
|
+
// it is never sent back, only its last few characters are (see the status
|
|
1360
|
+
// route) — and the route is loopback- and same-origin-gated like the config
|
|
1361
|
+
// bridge next to it.
|
|
1362
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1363
|
+
kind: 'exact',
|
|
1364
|
+
path: KEY_PATH,
|
|
1365
|
+
handler: async (req, res) => {
|
|
1366
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
1367
|
+
json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
1371
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const credentialsService = ctx.get('credentials');
|
|
1375
|
+
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
|
1376
|
+
json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
let body;
|
|
1380
|
+
try {
|
|
1381
|
+
body = await readJson(req);
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1387
|
+
if (!isValidRef(ref)) {
|
|
1388
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
if (req.method === 'DELETE') {
|
|
1393
|
+
await credentialsService.unset(ref);
|
|
1394
|
+
json(res, 200, { ok: true, ref });
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const value = typeof body?.value === 'string' ? body.value.trim() : '';
|
|
1398
|
+
if (value.length === 0) {
|
|
1399
|
+
json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
await credentialsService.set(ref, value);
|
|
1403
|
+
// #200: leak-detector hint - stored value should look like a credential
|
|
1404
|
+
const secretShape = looksLikeApiSecret(value);
|
|
1405
|
+
json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
// A ref supplied by the launching environment is read-only, and the
|
|
1408
|
+
// service says so in plain words — pass that through to the card.
|
|
1409
|
+
json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
|
|
1410
|
+
}
|
|
1411
|
+
},
|
|
1412
|
+
}), 'dsh-key-rotation: key route');
|
|
1413
|
+
|
|
1414
|
+
// ── reset route: clear cooldown for a provider (or a single ref) ──
|
|
1415
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1416
|
+
kind: 'exact',
|
|
1417
|
+
path: RESET_PATH,
|
|
1418
|
+
handler: async (req, res) => {
|
|
1419
|
+
if (req.method !== 'POST') {
|
|
1420
|
+
json(res, 405, { error: { code: 'method', message: 'POST only' } });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
1424
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
let body;
|
|
1428
|
+
try { body = await readJson(req); } catch (e) {
|
|
1429
|
+
json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
|
|
1433
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1434
|
+
if (provider) {
|
|
1435
|
+
const st = poolState.get(provider);
|
|
1436
|
+
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
1437
|
+
const cleared = st.failedUntil.size;
|
|
1438
|
+
st.failedUntil.clear();
|
|
1439
|
+
st.failCounts?.clear();
|
|
1440
|
+
st.authFailCounts?.clear();
|
|
1441
|
+
st.brokenUntil?.clear();
|
|
1442
|
+
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
1443
|
+
json(res, 200, { ok: true, provider, cleared });
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (ref) {
|
|
1447
|
+
let found = false;
|
|
1448
|
+
for (const st of poolState.values()) {
|
|
1449
|
+
if (st.failedUntil.has(ref) || st.failCounts?.has(ref)) {
|
|
1450
|
+
st.failedUntil.delete(ref);
|
|
1451
|
+
st.failCounts?.delete(ref);
|
|
1452
|
+
st.authFailCounts?.delete(ref);
|
|
1453
|
+
st.brokenUntil?.delete(ref);
|
|
1454
|
+
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
1455
|
+
found = true; break;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
|
|
1459
|
+
if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
1460
|
+
json(res, 200, { ok: true, ref });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
|
|
1464
|
+
},
|
|
1465
|
+
}), 'dsh-key-rotation: reset route');
|
|
1466
|
+
|
|
1467
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1468
|
+
kind: 'exact',
|
|
1469
|
+
path: IMPORT_PATH,
|
|
1470
|
+
handler: async (req, res) => {
|
|
1471
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1472
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
|
|
1473
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1474
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
1475
|
+
if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
|
|
1476
|
+
try {
|
|
1477
|
+
const resp = await fetch(url);
|
|
1478
|
+
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
1479
|
+
const data = await resp.json();
|
|
1480
|
+
if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
|
|
1481
|
+
const settings = ctx.get('settings');
|
|
1482
|
+
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
1483
|
+
const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
|
|
1484
|
+
const cur = desc?.value?.providers ?? [];
|
|
1485
|
+
const merged = new Map();
|
|
1486
|
+
for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
|
|
1487
|
+
for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
|
|
1488
|
+
const mergedArr = [...merged.values()];
|
|
1489
|
+
await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
|
|
1490
|
+
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
1491
|
+
} catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
|
|
1492
|
+
},
|
|
1493
|
+
}), 'dsh-key-rotation: import route');
|
|
1494
|
+
|
|
1495
|
+
// Health for external panels (Beszel/Uptime)
|
|
1496
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1497
|
+
kind: 'exact',
|
|
1498
|
+
path: HEALTH_PATH,
|
|
1499
|
+
handler: async (req, res) => {
|
|
1500
|
+
if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
|
|
1501
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
1502
|
+
// also allow plain loopback without Origin
|
|
1503
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
|
|
1504
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
|
|
1505
|
+
}
|
|
1506
|
+
if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
|
|
1507
|
+
const now = Date.now();
|
|
1508
|
+
const pools = {};
|
|
1509
|
+
let exhaustedAny = false;
|
|
1510
|
+
const { poolByRef: pr, providerTags } = buildRuntime();
|
|
1511
|
+
const seenH = new Set();
|
|
1512
|
+
for (const pool of pr.values()) {
|
|
1513
|
+
if (seenH.has(pool.base)) continue;
|
|
1514
|
+
seenH.add(pool.base);
|
|
1515
|
+
let healthy = 0;
|
|
1516
|
+
for (const ref of pool.refs) {
|
|
1517
|
+
const until = pool.state.failedUntil.get(ref);
|
|
1518
|
+
if (until !== undefined && until > now) continue;
|
|
1519
|
+
const exp = pool.expiresAt?.[ref];
|
|
1520
|
+
if (exp !== undefined && now >= exp) continue;
|
|
1521
|
+
healthy++;
|
|
1522
|
+
}
|
|
1523
|
+
const total = pool.refs.length;
|
|
1524
|
+
const exhausted = healthy === 0 && total > 0;
|
|
1525
|
+
if (exhausted) exhaustedAny = true;
|
|
1526
|
+
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
1527
|
+
}
|
|
1528
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: quotaStore.snapshot() });
|
|
1529
|
+
},
|
|
1530
|
+
}), 'dsh-key-rotation: health');
|
|
1531
|
+
|
|
1532
|
+
// ── test route: dry-run a single key without rotation ──
|
|
1533
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1534
|
+
kind: 'exact',
|
|
1535
|
+
path: TEST_PATH,
|
|
1536
|
+
handler: async (req, res) => {
|
|
1537
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1538
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
|
|
1539
|
+
let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1540
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
1541
|
+
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
1542
|
+
// Optional value for pre-save validation (issue #118)
|
|
1543
|
+
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
1544
|
+
const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
|
|
1545
|
+
const base = ctx.get('credentials');
|
|
1546
|
+
try {
|
|
1547
|
+
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
1548
|
+
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
1549
|
+
const effectiveValue = testValue || hit?.value;
|
|
1550
|
+
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
1551
|
+
const tail = valid ? keyTail(effectiveValue) : '';
|
|
1552
|
+
let source = null;
|
|
1553
|
+
try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
|
|
1554
|
+
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
1555
|
+
if (!present && testValue) { source = 'pre-save'; }
|
|
1556
|
+
else if (!present) {
|
|
1557
|
+
const ev = envValue(ref);
|
|
1558
|
+
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
1559
|
+
}
|
|
1560
|
+
// sandbox probe (models is free; chat is hook-only, see sandbox.js)
|
|
1561
|
+
if (probe) {
|
|
1562
|
+
const keyForProbe = effectiveValue;
|
|
1563
|
+
const runner = ensureSandboxRunner(ctx);
|
|
1564
|
+
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
1565
|
+
const cached = { ...result, at: Date.now() };
|
|
1566
|
+
lastTestCache.set(ref, cached);
|
|
1567
|
+
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
json(res, 200, { ok: true, ref, tail, source });
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
1573
|
+
}
|
|
1574
|
+
},
|
|
1575
|
+
}), 'dsh-key-rotation: test route');
|
|
1576
|
+
|
|
1577
|
+
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
1578
|
+
// to a configured key pool; pass everything else (and internal dispatches)
|
|
1579
|
+
// straight through.
|
|
1580
|
+
// Read-only cache snapshot for clients (badge polling).
|
|
1581
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1582
|
+
kind: 'exact',
|
|
1583
|
+
path: SANDBOX_CACHE_PATH,
|
|
1584
|
+
handler: (req, res) => {
|
|
1585
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
|
|
1586
|
+
json(res, 200, lastTestCache.snapshot());
|
|
1587
|
+
},
|
|
1588
|
+
}), 'dsh-key-rotation: sandbox cache');
|
|
1589
|
+
|
|
1590
|
+
// Auto-incident reset (#8).
|
|
1591
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1592
|
+
kind: 'exact',
|
|
1593
|
+
path: INCIDENT_RESET_PATH,
|
|
1594
|
+
handler: (req, res) => {
|
|
1595
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: incident-reset is local-only' } }); return; }
|
|
1596
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1597
|
+
readJson(req).then((body) => {
|
|
1598
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
1599
|
+
if (provider) incidentReporter.resetCooldown(provider);
|
|
1600
|
+
else incidentReporter.resetCooldown();
|
|
1601
|
+
json(res, 200, { ok: true, reset: provider || 'all' });
|
|
1602
|
+
}).catch((e) => json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }));
|
|
1603
|
+
},
|
|
1604
|
+
}), 'dsh-key-rotation: incident-reset');
|
|
1605
|
+
|
|
1606
|
+
// #198: 1-click Health Matrix — parallel probe of all configured keys.
|
|
1607
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1608
|
+
kind: 'exact',
|
|
1609
|
+
path: TEST_MATRIX_PATH,
|
|
1610
|
+
handler: async (req, res) => {
|
|
1611
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1612
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: matrix is local-only' } }); return; }
|
|
1613
|
+
const cfg = getConfig();
|
|
1614
|
+
const runner = ensureSandboxRunner();
|
|
1615
|
+
if (!runner) { json(res, 500, { error: { code: 'no-runner', message: 'sandbox runner unavailable' } }); return; }
|
|
1616
|
+
const providers = Array.isArray(cfg?.providers) ? cfg.providers : [];
|
|
1617
|
+
const jobs = [];
|
|
1618
|
+
for (const p of providers) {
|
|
1619
|
+
for (const ref of (p.keys ?? [])) {
|
|
1620
|
+
if (typeof ref !== 'string' || !ref) continue;
|
|
1621
|
+
jobs.push((async () => {
|
|
1622
|
+
try {
|
|
1623
|
+
const probeResult = await runner.probeModels(ref, ref);
|
|
1624
|
+
return { provider: p.provider, ref, ok: probeResult.ok, code: probeResult.code, latencyMs: probeResult.latencyMs, modelsCount: probeResult.modelsCount ?? 0 };
|
|
1625
|
+
} catch (e) {
|
|
1626
|
+
return { provider: p.provider, ref, ok: false, code: 'error', latencyMs: 0, modelsCount: 0 };
|
|
1627
|
+
}
|
|
1628
|
+
})());
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const results = await Promise.all(jobs);
|
|
1632
|
+
json(res, 200, { at: Date.now(), total: results.length, ok: results.filter(r => r.ok).length, results });
|
|
1633
|
+
},
|
|
1634
|
+
}), 'dsh-key-rotation: test-matrix');
|
|
1635
|
+
|
|
1636
|
+
// Webhook test endpoint (#10): dry-run that validates webhookSender setup.
|
|
1637
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1638
|
+
kind: 'exact',
|
|
1639
|
+
path: WEBHOOK_TEST_PATH,
|
|
1640
|
+
handler: (req, res) => {
|
|
1641
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: webhook-test is local-only' } }); return; }
|
|
1642
|
+
json(res, 200, { ok: true, snapshot: webhookSender.snapshot() });
|
|
1643
|
+
},
|
|
1644
|
+
}), 'dsh-key-rotation: webhook-test');
|
|
1645
|
+
|
|
1646
|
+
// #199 webhook-action: interactive webhook buttons call back here.
|
|
1647
|
+
// Auth: bearer token from Config (external services like Telegram/Discord
|
|
1648
|
+
// cannot be same-origin, so a shared secret is the gate).
|
|
1649
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1650
|
+
kind: 'exact',
|
|
1651
|
+
path: '/dsh-key-rotation/webhook-action',
|
|
1652
|
+
handler: async (req, res) => {
|
|
1653
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
1654
|
+
const runtime = buildRuntime();
|
|
1655
|
+
const expected = runtime.webhookActionToken;
|
|
1656
|
+
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
1657
|
+
const auth = String(req.headers.authorization ?? '');
|
|
1658
|
+
if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
|
|
1659
|
+
let body;
|
|
1660
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
1661
|
+
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
1662
|
+
let action = typeof body?.action === 'string' ? body.action : '';
|
|
1663
|
+
if (!action && typeof body?.data === 'string') {
|
|
1664
|
+
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
1665
|
+
}
|
|
1666
|
+
if (!action && typeof body?.callback_data === 'string') {
|
|
1667
|
+
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
1668
|
+
}
|
|
1669
|
+
// #222: Telegram update envelope {update_id, callback_query:{data}}
|
|
1670
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
1671
|
+
try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
|
|
1672
|
+
}
|
|
1673
|
+
// #222: Telegram setWebhook registration helper
|
|
1674
|
+
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
1675
|
+
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
1676
|
+
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
1677
|
+
// derive the public URL from request headers; explicit URL wins
|
|
1678
|
+
const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
|
|
1679
|
+
try {
|
|
1680
|
+
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
1681
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1682
|
+
body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
|
|
1683
|
+
});
|
|
1684
|
+
const hookData = await hookRes.json().catch(() => ({}));
|
|
1685
|
+
json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
1688
|
+
}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
|
|
1692
|
+
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
1693
|
+
try {
|
|
1694
|
+
if (action === 'disable-rotation') {
|
|
1695
|
+
rotationDisabled = true;
|
|
1696
|
+
console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
1697
|
+
json(res, 200, { ok: true, action });
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (action === 'enable-rotation') {
|
|
1701
|
+
rotationDisabled = false;
|
|
1702
|
+
json(res, 200, { ok: true, action });
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
1706
|
+
const st = poolState.get(provider);
|
|
1707
|
+
if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
|
|
1708
|
+
if (action.startsWith('pause-')) {
|
|
1709
|
+
const until = Date.now() + 3600000; // 1h pause
|
|
1710
|
+
for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
1711
|
+
// also pause every key currently healthy
|
|
1712
|
+
for (const p of buildRuntime().poolByRef.values()) {
|
|
1713
|
+
if (p.base !== provider) continue;
|
|
1714
|
+
for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
1715
|
+
}
|
|
1716
|
+
console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
1717
|
+
json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const cleared = st.failedUntil.size;
|
|
1721
|
+
st.failedUntil.clear(); st.failCounts?.clear(); st.brokenUntil?.clear();
|
|
1722
|
+
console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action`);
|
|
1723
|
+
json(res, 200, { ok: true, action, provider, cleared });
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
}), 'dsh-key-rotation: webhook-action');
|
|
1732
|
+
|
|
1733
|
+
// Shadow A/B sampling snapshot (#9).
|
|
1734
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1735
|
+
kind: 'exact',
|
|
1736
|
+
path: SHADOW_PATH,
|
|
1737
|
+
handler: (req, res) => {
|
|
1738
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: shadow is local-only' } }); return; }
|
|
1739
|
+
json(res, 200, shadowRouter.snapshot());
|
|
1740
|
+
},
|
|
1741
|
+
}), 'dsh-key-rotation: shadow');
|
|
1742
|
+
|
|
1743
|
+
// Region tags + failover chain (#4).
|
|
1744
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1745
|
+
kind: 'exact',
|
|
1746
|
+
path: REGIONS_PATH,
|
|
1747
|
+
handler: (req, res) => {
|
|
1748
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: regions is local-only' } }); return; }
|
|
1749
|
+
const body = regionMap.snapshot();
|
|
1750
|
+
// Add pickFallback hints per provider for inspection.
|
|
1751
|
+
const out = {};
|
|
1752
|
+
for (const p of Object.keys(body)) out[p] = { region: body[p], fallback: regionMap.pickFallback(p) };
|
|
1753
|
+
json(res, 200, out);
|
|
1754
|
+
},
|
|
1755
|
+
}), 'dsh-key-rotation: regions');
|
|
1756
|
+
|
|
1757
|
+
// Per-agent rate budget snapshot (#3).
|
|
1758
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1759
|
+
kind: 'exact',
|
|
1760
|
+
path: AGENT_BUDGET_PATH,
|
|
1761
|
+
handler: (req, res) => {
|
|
1762
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: agent-budget is local-only' } }); return; }
|
|
1763
|
+
json(res, 200, { enabled: agentBudget.isEnabled(), agents: agentBudget.snapshot() });
|
|
1764
|
+
},
|
|
1765
|
+
}), 'dsh-key-rotation: agent-budget');
|
|
1766
|
+
|
|
1767
|
+
ctx.on('llm/stream', (options, next) => {
|
|
1768
|
+
if (options[MARKER]) return next();
|
|
1769
|
+
if (rotationDisabled) return next(); // #199: disabled via webhook action
|
|
1770
|
+
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
|
1771
|
+
// #195: exact model pool -> longest model-family prefix -> provider pool
|
|
1772
|
+
const pool = selectPool(modelPoolByProvider, providerToPool, options.provider, options.model);
|
|
1773
|
+
if (!pool) return next();
|
|
1774
|
+
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
|
|
1775
|
+
return rotate(options, pool);
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
// Safety net for non-stream requests (agent/request-error waterfall).
|
|
1779
|
+
// llm/stream covers streaming calls; sync calls (embeddings, batch) go
|
|
1780
|
+
// through agent/request and surface errors here. If the error is
|
|
1781
|
+
// switchable, mark the key and ask the agent loop to retry.
|
|
1782
|
+
ctx.on('agent/request-error', async (payload, next) => {
|
|
1783
|
+
const provider = payload?.provider ?? payload?.failure?.provider ?? '';
|
|
1784
|
+
if (!provider) return next();
|
|
1785
|
+
const { providerToPool, modelPoolByProvider, switchCodes } = buildRuntime();
|
|
1786
|
+
const model = payload?.model || payload?.failure?.model || '';
|
|
1787
|
+
// #195: same tier-aware selection as llm/stream
|
|
1788
|
+
const pool = selectPool(modelPoolByProvider, providerToPool, provider, model);
|
|
1789
|
+
if (!pool) return next();
|
|
1790
|
+
const code = String(payload?.failure?.code ?? payload?.code ?? '');
|
|
1791
|
+
const message = String(payload?.failure?.message ?? payload?.message ?? '');
|
|
1792
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
1793
|
+
const switchable = effectiveSwitchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message);
|
|
1794
|
+
if (!switchable) return next();
|
|
1795
|
+
const ref = pool.state.lastUsed;
|
|
1796
|
+
if (ref) {
|
|
1797
|
+
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000);
|
|
1798
|
+
pushEvent(pool, ref, code || 'UNKNOWN', backoff);
|
|
1799
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
1800
|
+
pool.state.lastReason = code || 'UNKNOWN';
|
|
1801
|
+
pool.state.lastSwitchAt = Date.now();
|
|
1802
|
+
console.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
|
|
1803
|
+
}
|
|
1804
|
+
return { kind: 'retry' };
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
ctx.inject(['settings'], (sctx) => {
|
|
1808
|
+
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
1809
|
+
getConfig = () => scope.get() ?? config;
|
|
1810
|
+
sctx.effect(() => () => {
|
|
1811
|
+
getConfig = () => config;
|
|
1812
|
+
});
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// Notify on exhaustion: webhook + (optional) GitHub incident.
|
|
1817
|
+
// Extracted at module scope for testability. No I/O outside the injected hooks.
|
|
1818
|
+
// ponytail: thresholds and URLs are runtime-resolved per call, so changing Config is reflected immediately.
|
|
1819
|
+
export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender, ensureIncidentReporter }) {
|
|
1820
|
+
if (!runtime || !pool) return;
|
|
1821
|
+
const count = pool.state ? (pool.state.exhaustionCount ?? 0) : 0;
|
|
1822
|
+
if (count <= 0) return;
|
|
1823
|
+
try {
|
|
1824
|
+
if (runtime.notifyWebhook && count >= (runtime.notifyThreshold ?? 0)) {
|
|
1825
|
+
// #199: interactive payload when an action token is configured - the
|
|
1826
|
+
// platform formatter (webhook.js) turns `actions` into buttons whose
|
|
1827
|
+
// callback carries the token back to /dsh-key-rotation/webhook-action.
|
|
1828
|
+
const token = runtime.webhookActionToken ?? '';
|
|
1829
|
+
const payload = {
|
|
1830
|
+
title: `Key pool exhausted: ${options.provider}`,
|
|
1831
|
+
text: `${count} exhaustion(s); keys: ${(pool.refs ?? []).join(', ')}`,
|
|
1832
|
+
provider: options.provider,
|
|
1833
|
+
exhaustionCount: count,
|
|
1834
|
+
at: pool.state.lastExhaustionAt,
|
|
1835
|
+
keys: pool.refs,
|
|
1836
|
+
actionToken: token || undefined,
|
|
1837
|
+
actions: token ? [
|
|
1838
|
+
{ id: `reset-${options.provider}`, label: 'Reset cooldown' },
|
|
1839
|
+
{ id: `pause-${options.provider}`, label: 'Pause 1h' },
|
|
1840
|
+
] : undefined,
|
|
1841
|
+
};
|
|
1842
|
+
hooks.webhookSender.send(runtime.notifyWebhook, payload);
|
|
1843
|
+
}
|
|
1844
|
+
if (runtime.incidentThreshold && count >= runtime.incidentThreshold) {
|
|
1845
|
+
const reporter = hooks.ensureIncidentReporter();
|
|
1846
|
+
if (reporter) reporter.open(options.provider, pool.state.lastExhaustionAt);
|
|
1847
|
+
}
|
|
1848
|
+
} catch (_) { /* ponytail: never crash rotate() */ }
|
|
1849
|
+
}
|
|
1850
|
+
|