@goodandready/dsh-model-sync 0.3.9 → 0.3.11
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 +21 -0
- package/README.ru.md +21 -0
- package/README.zh.md +10 -0
- package/lib/client.js +17 -15
- package/lib/history.js +14 -3
- package/lib/index.js +1 -1
- package/lib/provider-service.js +12 -3
- package/lib/scheduler.js +6 -0
- package/lib/synchronizer.js +645 -636
- package/package.json +1 -1
package/lib/synchronizer.js
CHANGED
|
@@ -1,636 +1,645 @@
|
|
|
1
|
-
import { listApiKeyProviders } from './inventory.js'
|
|
2
|
-
import { createProviderAdapterRegistry } from './adapter-registry.js'
|
|
3
|
-
import { createCredentialResolver } from './credentials.js'
|
|
4
|
-
import { reconcileModels, catalogPatch } from './reconcile.js'
|
|
5
|
-
import { normalizeModels } from './models.js'
|
|
6
|
-
import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
|
|
7
|
-
import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
|
|
8
|
-
import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
|
|
9
|
-
import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
|
|
10
|
-
import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
|
|
11
|
-
|
|
12
|
-
const PIAI_NS = 'llm-pi-ai'
|
|
13
|
-
const DEFAULT_RELIABILITY = Object.freeze({
|
|
14
|
-
timeoutMs: 15000,
|
|
15
|
-
retryAttempts: 3,
|
|
16
|
-
retryBaseDelayMs: 250,
|
|
17
|
-
retryMaxDelayMs: 4000,
|
|
18
|
-
concurrency: 4,
|
|
19
|
-
circuitBreakerFailures: 3,
|
|
20
|
-
circuitBreakerCooldownMs: 300000,
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
function boundedInteger(value, fallback, minimum, maximum) {
|
|
24
|
-
const number = Number(value)
|
|
25
|
-
if (!Number.isFinite(number)) return fallback
|
|
26
|
-
return Math.min(maximum, Math.max(minimum, Math.floor(number)))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function reliabilityOptions(config, options) {
|
|
30
|
-
const source = { ...config, ...options }
|
|
31
|
-
const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
|
|
32
|
-
const cooldownMs = source.circuitBreakerCooldownMs !== undefined
|
|
33
|
-
? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
34
|
-
: boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
35
|
-
return {
|
|
36
|
-
timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
|
|
37
|
-
retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
|
|
38
|
-
retryBaseDelayMs: baseDelay,
|
|
39
|
-
retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
|
|
40
|
-
concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
|
|
41
|
-
circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
|
|
42
|
-
circuitBreakerCooldownMs: cooldownMs,
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function settingsOf(ctx) {
|
|
47
|
-
try { return ctx.get('settings') } catch { return
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function
|
|
51
|
-
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return '
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
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
|
-
return {
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
recordCredentialOutcome(row.provider, { status: '
|
|
308
|
-
return {
|
|
309
|
-
provider: row.provider,
|
|
310
|
-
status: '
|
|
311
|
-
statusCode:
|
|
312
|
-
latencyMs: Date.now() - startedAt,
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
const
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
...
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
selectedModels
|
|
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
|
-
return
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const config = getConfig() ?? {}
|
|
503
|
-
const
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
1
|
+
import { listApiKeyProviders } from './inventory.js'
|
|
2
|
+
import { createProviderAdapterRegistry } from './adapter-registry.js'
|
|
3
|
+
import { createCredentialResolver } from './credentials.js'
|
|
4
|
+
import { reconcileModels, catalogPatch } from './reconcile.js'
|
|
5
|
+
import { normalizeModels } from './models.js'
|
|
6
|
+
import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
|
|
7
|
+
import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
|
|
8
|
+
import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
|
|
9
|
+
import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
|
|
10
|
+
import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
|
|
11
|
+
|
|
12
|
+
const PIAI_NS = 'llm-pi-ai'
|
|
13
|
+
const DEFAULT_RELIABILITY = Object.freeze({
|
|
14
|
+
timeoutMs: 15000,
|
|
15
|
+
retryAttempts: 3,
|
|
16
|
+
retryBaseDelayMs: 250,
|
|
17
|
+
retryMaxDelayMs: 4000,
|
|
18
|
+
concurrency: 4,
|
|
19
|
+
circuitBreakerFailures: 3,
|
|
20
|
+
circuitBreakerCooldownMs: 300000,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
24
|
+
const number = Number(value)
|
|
25
|
+
if (!Number.isFinite(number)) return fallback
|
|
26
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(number)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function reliabilityOptions(config, options) {
|
|
30
|
+
const source = { ...config, ...options }
|
|
31
|
+
const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
|
|
32
|
+
const cooldownMs = source.circuitBreakerCooldownMs !== undefined
|
|
33
|
+
? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
34
|
+
: boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
35
|
+
return {
|
|
36
|
+
timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
|
|
37
|
+
retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
|
|
38
|
+
retryBaseDelayMs: baseDelay,
|
|
39
|
+
retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
|
|
40
|
+
concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
|
|
41
|
+
circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
|
|
42
|
+
circuitBreakerCooldownMs: cooldownMs,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function settingsOf(ctx) {
|
|
47
|
+
try { return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings) } catch { return ctx.settings }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function llmOf(ctx) {
|
|
51
|
+
try {
|
|
52
|
+
const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
|
|
53
|
+
return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
|
|
54
|
+
} catch {
|
|
55
|
+
return ctx.llm
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function descriptorOf(settings, ns) {
|
|
60
|
+
try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
|
|
61
|
+
catch { return undefined }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function profileOf(settings, provider) {
|
|
65
|
+
try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
|
|
66
|
+
catch { return {} }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function errorMessage(error) {
|
|
70
|
+
return error instanceof Error ? error.message : String(error)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function diagnosticMessage(error) {
|
|
74
|
+
return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function adapterErrorType(error) {
|
|
78
|
+
if (error?.adapterCode === 'auth') return 'auth'
|
|
79
|
+
if (error?.adapterCode === 'rate-limit') return 'rate-limit'
|
|
80
|
+
if (error?.adapterCode === 'schema') return 'schema'
|
|
81
|
+
if (error?.adapterCode === 'endpoint') return 'endpoint'
|
|
82
|
+
if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
|
|
83
|
+
const status = statusOfError(error)
|
|
84
|
+
if (status === 401 || status === 403) return 'auth'
|
|
85
|
+
if (status === 429) return 'rate-limit'
|
|
86
|
+
if (status) return 'http'
|
|
87
|
+
return 'endpoint'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
|
|
91
|
+
let lastError
|
|
92
|
+
for (let i = 0; i < attempts; i++) {
|
|
93
|
+
const section = settings?.get?.(ns)
|
|
94
|
+
const descriptor = descriptorOf(settings, ns)
|
|
95
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
96
|
+
throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
|
|
97
|
+
}
|
|
98
|
+
const next = patchFn(structuredClone(section))
|
|
99
|
+
try {
|
|
100
|
+
await settings.replace(ns, next, descriptor.revision)
|
|
101
|
+
return next
|
|
102
|
+
} catch (err) {
|
|
103
|
+
lastError = err
|
|
104
|
+
const isConflict = err?.code === 'SETTINGS_CONFLICT' || err?.status === 409 || /conflict|revision/i.test(err?.message || '')
|
|
105
|
+
if (isConflict && i < attempts - 1) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
throw err
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw lastError
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rotationRefs(settings, provider) {
|
|
115
|
+
try {
|
|
116
|
+
const rows = settings?.get?.('dsh-key-rotation')?.providers
|
|
117
|
+
if (!Array.isArray(rows)) return []
|
|
118
|
+
return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
|
|
119
|
+
} catch {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
|
|
125
|
+
const resolveCredential = createCredentialResolver(ctx)
|
|
126
|
+
const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl, adapters: adapterImplementations })
|
|
127
|
+
let running = null
|
|
128
|
+
let lastRun = null
|
|
129
|
+
let lastHealth = null
|
|
130
|
+
let healthRunning = null
|
|
131
|
+
const circuits = new Map()
|
|
132
|
+
const credentialOutcomes = new Map()
|
|
133
|
+
let lastReport = null
|
|
134
|
+
let transientNotifications = []
|
|
135
|
+
|
|
136
|
+
function recordCredentialOutcome(provider, outcome) {
|
|
137
|
+
credentialOutcomes.set(provider, { ...outcome, at: Date.now() })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function selectAdapter(profile) {
|
|
141
|
+
const config = getConfig() ?? {}
|
|
142
|
+
return registry.select(profile, config.adapterRegistry?.[profile.provider])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function refsFor(row, settings) {
|
|
146
|
+
const profile = profileOf(settings, row.provider)
|
|
147
|
+
const refs = [
|
|
148
|
+
row.apiKeyEnv,
|
|
149
|
+
profile.credentialRef,
|
|
150
|
+
...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
|
|
151
|
+
...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
|
|
152
|
+
...rotationRefs(settings, row.provider),
|
|
153
|
+
]
|
|
154
|
+
return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function circuitOpen(provider, reliability) {
|
|
158
|
+
if (reliability.circuitBreakerFailures <= 0) return null
|
|
159
|
+
const state = circuits.get(provider)
|
|
160
|
+
if (!state) return null
|
|
161
|
+
if (state.blockedUntil > Date.now()) {
|
|
162
|
+
return { retryAfterMs: state.blockedUntil - Date.now() }
|
|
163
|
+
}
|
|
164
|
+
if (state.blockedUntil) circuits.delete(provider)
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function recordFailure(provider, reliability, error) {
|
|
169
|
+
if (reliability.circuitBreakerFailures <= 0 || !isRetryableError(error)) return
|
|
170
|
+
const state = circuits.get(provider) ?? { failures: 0, blockedUntil: 0 }
|
|
171
|
+
state.failures += 1
|
|
172
|
+
if (state.failures >= reliability.circuitBreakerFailures) {
|
|
173
|
+
state.blockedUntil = Date.now() + reliability.circuitBreakerCooldownMs
|
|
174
|
+
}
|
|
175
|
+
circuits.set(provider, state)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function policyFor(config, provider) {
|
|
179
|
+
return normalizePolicy(config?.modelPolicies?.[provider])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function providers() {
|
|
183
|
+
const config = getConfig() ?? {}
|
|
184
|
+
const lastResults = new Map((lastRun?.results ?? []).map((row) => [row.provider, row]))
|
|
185
|
+
return listApiKeyProviders(llmOf(ctx), settingsOf(ctx)).map((row) => {
|
|
186
|
+
const selection = Array.isArray(config.modelSelections?.[row.provider])
|
|
187
|
+
? [...new Set(config.modelSelections[row.provider].filter((id) => typeof id === 'string' && id))]
|
|
188
|
+
: []
|
|
189
|
+
const latest = lastResults.get(row.provider)
|
|
190
|
+
const policy = policyFor(config, row.provider)
|
|
191
|
+
const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider])
|
|
192
|
+
const available = latest?.status === 'ok'
|
|
193
|
+
? latest.availableModels ?? latest.models ?? row.models
|
|
194
|
+
: cached.length > 0 ? cached : row.models
|
|
195
|
+
const availableModels = filterModels(available, policy)
|
|
196
|
+
const lifecycleModels = lifecycleStatus(config.modelLifecycle, row.provider, availableModels)
|
|
197
|
+
return {
|
|
198
|
+
...row,
|
|
199
|
+
selectedModels: selection,
|
|
200
|
+
policy,
|
|
201
|
+
availableModels,
|
|
202
|
+
lifecycleModels,
|
|
203
|
+
selectableModels: lifecycleModels.filter((model) => model.lifecycleStatus === 'active'),
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function listPolicies() {
|
|
209
|
+
const config = getConfig() ?? {}
|
|
210
|
+
return Object.fromEntries(providers()
|
|
211
|
+
.filter((row) => row.configured)
|
|
212
|
+
.map((row) => [row.provider, policyFor(config, row.provider)]))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function selectionFor(config, provider) {
|
|
216
|
+
const selection = config?.modelSelections?.[provider]
|
|
217
|
+
return Array.isArray(selection) ? [...new Set(selection.filter((id) => typeof id === 'string' && id))] : []
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function discover(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
|
|
221
|
+
const settings = settingsOf(ctx)
|
|
222
|
+
const profile = { ...profileOf(settings, row.provider), provider: row.provider }
|
|
223
|
+
let adapter
|
|
224
|
+
try { adapter = selectAdapter(profile) } catch (error) {
|
|
225
|
+
return { provider: row.provider, status: 'invalid-config', models: [], message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
|
|
226
|
+
}
|
|
227
|
+
if (!adapter) return { provider: row.provider, status: 'unsupported', models: [], message: 'no supported API-key model adapter' }
|
|
228
|
+
const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
|
|
229
|
+
if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
|
|
230
|
+
return { provider: row.provider, status: 'dormant', models: [], message: 'provider has no API key route configured' }
|
|
231
|
+
}
|
|
232
|
+
const open = circuitOpen(row.provider, reliability)
|
|
233
|
+
if (open) {
|
|
234
|
+
return {
|
|
235
|
+
provider: row.provider,
|
|
236
|
+
status: 'circuit-open',
|
|
237
|
+
models: [],
|
|
238
|
+
message: 'provider circuit is open after repeated transient failures',
|
|
239
|
+
retryAfterMs: open.retryAfterMs,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
let retries = 0
|
|
243
|
+
try {
|
|
244
|
+
const models = await retryWithBackoff(async () => {
|
|
245
|
+
const timed = createTimeoutSignal(signal, reliability.timeoutMs)
|
|
246
|
+
try {
|
|
247
|
+
return await adapter.discover(profile, { signal: timed.signal })
|
|
248
|
+
} finally {
|
|
249
|
+
timed.cleanup()
|
|
250
|
+
}
|
|
251
|
+
}, {
|
|
252
|
+
attempts: reliability.retryAttempts,
|
|
253
|
+
baseDelayMs: reliability.retryBaseDelayMs,
|
|
254
|
+
maxDelayMs: reliability.retryMaxDelayMs,
|
|
255
|
+
signal,
|
|
256
|
+
onRetry: () => { retries += 1 },
|
|
257
|
+
})
|
|
258
|
+
circuits.delete(row.provider)
|
|
259
|
+
recordCredentialOutcome(row.provider, { status: 'ok', retries })
|
|
260
|
+
return { provider: row.provider, status: 'ok', models, retries }
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!signal?.aborted) recordFailure(row.provider, reliability, error)
|
|
263
|
+
const state = circuits.get(row.provider)
|
|
264
|
+
recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
|
|
265
|
+
return {
|
|
266
|
+
provider: row.provider,
|
|
267
|
+
status: 'error',
|
|
268
|
+
models: [],
|
|
269
|
+
message: diagnosticMessage(error),
|
|
270
|
+
errorType: adapterErrorType(error),
|
|
271
|
+
retries,
|
|
272
|
+
retryable: isRetryableError(error),
|
|
273
|
+
circuitOpen: Boolean(state?.blockedUntil > Date.now()),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function probe(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
|
|
279
|
+
const settings = settingsOf(ctx)
|
|
280
|
+
const profile = { ...profileOf(settings, row.provider), provider: row.provider }
|
|
281
|
+
let adapter
|
|
282
|
+
try { adapter = selectAdapter(profile) } catch (error) {
|
|
283
|
+
return { provider: row.provider, status: 'invalid-config', message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
|
|
284
|
+
}
|
|
285
|
+
if (!adapter?.health) return { provider: row.provider, status: 'unsupported', message: 'provider has no health probe' }
|
|
286
|
+
const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
|
|
287
|
+
if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
|
|
288
|
+
return { provider: row.provider, status: 'dormant', message: 'provider has no API key route configured' }
|
|
289
|
+
}
|
|
290
|
+
const startedAt = Date.now()
|
|
291
|
+
let retries = 0
|
|
292
|
+
try {
|
|
293
|
+
const result = await retryWithBackoff(async () => {
|
|
294
|
+
const timed = createTimeoutSignal(signal, reliability.timeoutMs)
|
|
295
|
+
try {
|
|
296
|
+
return await adapter.health(profile, { signal: timed.signal })
|
|
297
|
+
} finally {
|
|
298
|
+
timed.cleanup()
|
|
299
|
+
}
|
|
300
|
+
}, {
|
|
301
|
+
attempts: reliability.retryAttempts,
|
|
302
|
+
baseDelayMs: reliability.retryBaseDelayMs,
|
|
303
|
+
maxDelayMs: reliability.retryMaxDelayMs,
|
|
304
|
+
signal,
|
|
305
|
+
onRetry: () => { retries += 1 },
|
|
306
|
+
})
|
|
307
|
+
recordCredentialOutcome(row.provider, { status: 'ok', code: result?.statusCode ?? 200, retries })
|
|
308
|
+
return {
|
|
309
|
+
provider: row.provider,
|
|
310
|
+
status: 'ok',
|
|
311
|
+
statusCode: result?.statusCode ?? 200,
|
|
312
|
+
latencyMs: result?.latencyMs ?? Date.now() - startedAt,
|
|
313
|
+
retries,
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
|
|
317
|
+
return {
|
|
318
|
+
provider: row.provider,
|
|
319
|
+
status: 'error',
|
|
320
|
+
statusCode: statusOfError(error),
|
|
321
|
+
latencyMs: Date.now() - startedAt,
|
|
322
|
+
message: diagnosticMessage(error),
|
|
323
|
+
errorType: adapterErrorType(error),
|
|
324
|
+
retries,
|
|
325
|
+
retryable: isRetryableError(error),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function credentialDiagnostics(options = {}) {
|
|
331
|
+
const settings = settingsOf(ctx)
|
|
332
|
+
const selected = typeof options.provider === 'string' && options.provider
|
|
333
|
+
? providers().filter((row) => row.provider === options.provider && row.configured)
|
|
334
|
+
: providers().filter((row) => row.configured)
|
|
335
|
+
const results = await Promise.all(selected.map(async (row) => {
|
|
336
|
+
const refs = refsFor(row, settings)
|
|
337
|
+
const rotation = rotationRefs(settings, row.provider)
|
|
338
|
+
const diagnostics = typeof resolveCredential.diagnostics === 'function'
|
|
339
|
+
? await resolveCredential.diagnostics(refs)
|
|
340
|
+
: []
|
|
341
|
+
return {
|
|
342
|
+
provider: row.provider,
|
|
343
|
+
displayName: row.displayName,
|
|
344
|
+
refs: diagnostics,
|
|
345
|
+
rotation: {
|
|
346
|
+
configured: rotation.length > 0,
|
|
347
|
+
keyCount: rotation.length,
|
|
348
|
+
fallbackOrder: diagnostics.filter((item) => rotation.includes(item.ref)).map((item) => item.label),
|
|
349
|
+
},
|
|
350
|
+
...(credentialOutcomes.has(row.provider) ? { lastRequest: structuredClone(credentialOutcomes.get(row.provider)) } : {}),
|
|
351
|
+
}
|
|
352
|
+
}))
|
|
353
|
+
return { checkedAt: Date.now(), results }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function health(options = {}) {
|
|
357
|
+
if (healthRunning) return healthRunning
|
|
358
|
+
const config = getConfig() ?? {}
|
|
359
|
+
const reliability = reliabilityOptions(config, options)
|
|
360
|
+
const providerConfig = Array.isArray(config.providers) ? config.providers : []
|
|
361
|
+
const selected = typeof options.provider === 'string' && options.provider
|
|
362
|
+
? providers().filter((row) => row.provider === options.provider)
|
|
363
|
+
: providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
|
|
364
|
+
const startedAt = Date.now()
|
|
365
|
+
const task = (async () => {
|
|
366
|
+
const results = await mapWithConcurrency(
|
|
367
|
+
selected,
|
|
368
|
+
(row) => probe(row, { signal: options.signal, reliability }),
|
|
369
|
+
reliability.concurrency,
|
|
370
|
+
)
|
|
371
|
+
const snapshot = { startedAt, finishedAt: Date.now(), reliability, results }
|
|
372
|
+
lastHealth = snapshot
|
|
373
|
+
return snapshot
|
|
374
|
+
})()
|
|
375
|
+
healthRunning = task.finally(() => { healthRunning = null })
|
|
376
|
+
return healthRunning
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function run(options = {}) {
|
|
380
|
+
if (running) return running
|
|
381
|
+
const config = getConfig() ?? {}
|
|
382
|
+
const reliability = reliabilityOptions(config, options)
|
|
383
|
+
const cachedCatalogs = structuredClone(config.modelCatalogs ?? {})
|
|
384
|
+
const nextLifecycle = structuredClone(config.modelLifecycle ?? {})
|
|
385
|
+
const lifecycleRun = (Number(config.lifecycleRevision) || 0) + 1
|
|
386
|
+
const lifecycleConfig = lifecycleOptions(config)
|
|
387
|
+
const dryRun = options.dryRun !== false
|
|
388
|
+
const removeMissing = options.removeMissing === true
|
|
389
|
+
const providerConfig = Array.isArray(config.providers) ? config.providers : []
|
|
390
|
+
const globalAutoApply = config.autoApply === true
|
|
391
|
+
const providerAutoApply = (provider) => { const entry = providerConfig.find((item) => item.provider === provider); return entry && typeof entry.autoApply === 'boolean' ? entry.autoApply : globalAutoApply }
|
|
392
|
+
const selected = typeof options.provider === 'string' && options.provider
|
|
393
|
+
? providers().filter((row) => row.provider === options.provider)
|
|
394
|
+
: providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
|
|
395
|
+
const startedAt = Date.now()
|
|
396
|
+
const task = (async () => {
|
|
397
|
+
const discoveredRows = await mapWithConcurrency(
|
|
398
|
+
selected,
|
|
399
|
+
(row) => discover(row, { signal: options.signal, reliability }),
|
|
400
|
+
reliability.concurrency,
|
|
401
|
+
)
|
|
402
|
+
const results = []
|
|
403
|
+
for (let index = 0; index < selected.length; index += 1) {
|
|
404
|
+
const row = selected[index]
|
|
405
|
+
const discovered = discoveredRows[index]
|
|
406
|
+
if (discovered.status !== 'ok') {
|
|
407
|
+
results.push({ ...discovered, changed: false })
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
const settings = settingsOf(ctx)
|
|
411
|
+
const current = profileOf(settings, row.provider).models ?? row.models
|
|
412
|
+
const fullModels = normalizeModels(row.provider, discovered.models)
|
|
413
|
+
const lifecycleBefore = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? current)
|
|
414
|
+
const lifecycle = updateLifecycle(config.modelLifecycle?.[row.provider], lifecycleBefore, fullModels, {
|
|
415
|
+
runNumber: lifecycleRun,
|
|
416
|
+
...lifecycleConfig,
|
|
417
|
+
removeMissing,
|
|
418
|
+
})
|
|
419
|
+
nextLifecycle[row.provider] = lifecycle.records
|
|
420
|
+
const policy = policyFor(config, row.provider)
|
|
421
|
+
const policyModels = filterModels(fullModels, policy)
|
|
422
|
+
const selectedModels = selectionFor(config, row.provider)
|
|
423
|
+
const advertised = selectedModels.length
|
|
424
|
+
? policyModels.filter((model) => selectedModels.includes(model.id))
|
|
425
|
+
: policyModels
|
|
426
|
+
const explicitRemoveIds = new Set(lifecycle.removedIds)
|
|
427
|
+
const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
|
|
428
|
+
results.push({
|
|
429
|
+
...discovered,
|
|
430
|
+
discoveredModels: fullModels,
|
|
431
|
+
availableModels: policyModels,
|
|
432
|
+
policy,
|
|
433
|
+
selectedModels,
|
|
434
|
+
advertised,
|
|
435
|
+
lifecycle: lifecycle.statuses,
|
|
436
|
+
lifecycleChanges: lifecycle.changes,
|
|
437
|
+
...reconciliation,
|
|
438
|
+
removeMissing: lifecycle.removedIds.length > 0,
|
|
439
|
+
changed: reconciliation.changed,
|
|
440
|
+
})
|
|
441
|
+
const freshIds = new Set(fullModels.map((model) => model.id))
|
|
442
|
+
const removedIds = new Set(lifecycle.removedIds)
|
|
443
|
+
const retainedCatalog = lifecycleBefore.filter((model) => !freshIds.has(model.id) && !removedIds.has(model.id))
|
|
444
|
+
cachedCatalogs[row.provider] = normalizeModels(row.provider, [...retainedCatalog, ...fullModels])
|
|
445
|
+
}
|
|
446
|
+
const result = { startedAt, finishedAt: Date.now(), dryRun, removeMissing, reliability, lifecycleRevision: lifecycleRun, results, applied: false }
|
|
447
|
+
const report = createSyncReport(result, { source: options.reportSource === 'schedule' ? 'schedule' : 'manual' })
|
|
448
|
+
lastReport = report
|
|
449
|
+
const notificationLimit = config.notificationLimit
|
|
450
|
+
transientNotifications = appendNotification(transientNotifications, report, { limit: notificationLimit })
|
|
451
|
+
result.report = report
|
|
452
|
+
const resultsToApply = results.filter((row) => row.status === 'ok' && row.changed && (!dryRun || providerAutoApply(row.provider)))
|
|
453
|
+
if (resultsToApply.length > 0) {
|
|
454
|
+
const settings = settingsOf(ctx)
|
|
455
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => {
|
|
456
|
+
let next = section
|
|
457
|
+
for (const row of resultsToApply) {
|
|
458
|
+
next = catalogPatch(next, row.provider, row.next)
|
|
459
|
+
}
|
|
460
|
+
return next
|
|
461
|
+
})
|
|
462
|
+
result.applied = true
|
|
463
|
+
}
|
|
464
|
+
if (!dryRun || resultsToApply.length > 0) {
|
|
465
|
+
const previousCatalogs = config.modelCatalogs ?? {}
|
|
466
|
+
const patch = { modelLifecycle: nextLifecycle, lifecycleRevision: lifecycleRun }
|
|
467
|
+
if (results.some((row) => row.changed) || Object.keys(previousCatalogs).length !== Object.keys(cachedCatalogs).length || JSON.stringify(previousCatalogs) !== JSON.stringify(cachedCatalogs)) patch.modelCatalogs = cachedCatalogs
|
|
468
|
+
const previousHistory = Array.isArray(config.history) ? config.history : []
|
|
469
|
+
const nextVersion = previousHistory.reduce((max, entry) => Math.max(max, Number(entry?.version) || 0), 0) + 1
|
|
470
|
+
const entry = createHistoryEntry(result, { version: nextVersion })
|
|
471
|
+
patch.history = appendHistory(previousHistory, entry, config.historyLimit)
|
|
472
|
+
const previousNotifications = Array.isArray(config.notifications) ? config.notifications : []
|
|
473
|
+
const nextNotifications = appendNotification(previousNotifications, report, { limit: config.notificationLimit })
|
|
474
|
+
if (JSON.stringify(previousNotifications) !== JSON.stringify(nextNotifications)) patch.notifications = nextNotifications
|
|
475
|
+
result.historyId = entry.id
|
|
476
|
+
try {
|
|
477
|
+
await saveConfig(patch)
|
|
478
|
+
if (patch.modelCatalogs) result.catalogCachePersisted = true
|
|
479
|
+
result.historyPersisted = true
|
|
480
|
+
} catch (error) {
|
|
481
|
+
// The active DSH catalog has already been revision-checked. A cache
|
|
482
|
+
// or history failure is visible but must not undo that successful
|
|
483
|
+
// provider update.
|
|
484
|
+
if (patch.modelCatalogs) result.catalogCacheError = errorMessage(error)
|
|
485
|
+
result.historyError = errorMessage(error)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
result.notifications = notificationLedger()
|
|
489
|
+
lastRun = result
|
|
490
|
+
return result
|
|
491
|
+
})()
|
|
492
|
+
running = task.finally(() => { running = null })
|
|
493
|
+
return running
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function history(options = {}) {
|
|
497
|
+
const config = getConfig() ?? {}
|
|
498
|
+
return listHistory(config.history, options)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function notificationLedger(options = {}) {
|
|
502
|
+
const config = getConfig() ?? {}
|
|
503
|
+
const byId = new Map()
|
|
504
|
+
for (const row of [...(Array.isArray(config.notifications) ? config.notifications : []), ...transientNotifications]) {
|
|
505
|
+
if (row?.id) byId.set(row.id, row)
|
|
506
|
+
}
|
|
507
|
+
return listNotifications([...byId.values()], options)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function updateNotificationState(id, field) {
|
|
511
|
+
const config = getConfig() ?? {}
|
|
512
|
+
const persisted = Array.isArray(config.notifications) ? config.notifications : []
|
|
513
|
+
if (persisted.some((row) => row?.id === id)) {
|
|
514
|
+
const next = updateNotification(persisted, id, field)
|
|
515
|
+
await saveConfig({ notifications: next })
|
|
516
|
+
transientNotifications = transientNotifications.filter((row) => row?.id !== id)
|
|
517
|
+
return next.find((row) => row.id === id)
|
|
518
|
+
}
|
|
519
|
+
transientNotifications = updateNotification(transientNotifications, id, field)
|
|
520
|
+
return transientNotifications.find((row) => row.id === id)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function tryModel({ provider, model } = {}) {
|
|
524
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
525
|
+
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
526
|
+
const start = Date.now()
|
|
527
|
+
// Use health as lightweight probe; latency is the signal
|
|
528
|
+
const result = await health({ provider })
|
|
529
|
+
const entry = result?.results?.find((r) => r.provider === provider)
|
|
530
|
+
return {
|
|
531
|
+
provider,
|
|
532
|
+
model,
|
|
533
|
+
ok: entry?.status === 'ok',
|
|
534
|
+
latencyMs: Date.now() - start,
|
|
535
|
+
status: entry?.status ?? 'unknown',
|
|
536
|
+
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function rollbackHistory({ historyId, provider } = {}) {
|
|
541
|
+
if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
|
|
542
|
+
if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
|
|
543
|
+
const config = getConfig() ?? {}
|
|
544
|
+
const entry = historyEntry(config.history, historyId)
|
|
545
|
+
if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
|
|
546
|
+
const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
|
|
547
|
+
if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
|
|
548
|
+
if (!providers().some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
|
|
549
|
+
const settings = settingsOf(ctx)
|
|
550
|
+
const section = settings?.get?.(PIAI_NS)
|
|
551
|
+
const descriptor = descriptorOf(settings, PIAI_NS)
|
|
552
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
553
|
+
throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
|
|
554
|
+
}
|
|
555
|
+
const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
|
|
556
|
+
const target = normalizeModels(provider, snapshot.before)
|
|
557
|
+
if (JSON.stringify(current) === JSON.stringify(target)) {
|
|
558
|
+
return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
|
|
559
|
+
}
|
|
560
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, target))
|
|
561
|
+
return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function setModelPolicy(provider, policy) {
|
|
565
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
566
|
+
const row = providers().find((item) => item.provider === provider && item.configured)
|
|
567
|
+
if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
|
|
568
|
+
const normalized = validatePolicy(policy)
|
|
569
|
+
const config = getConfig() ?? {}
|
|
570
|
+
const previous = structuredClone(config.modelPolicies ?? {})
|
|
571
|
+
const modelPolicies = { ...previous, [provider]: normalized }
|
|
572
|
+
await saveConfig({ modelPolicies })
|
|
573
|
+
return { provider, policy: normalized, applied: false }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function setModelSelection(provider, models) {
|
|
577
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
578
|
+
if (!Array.isArray(models)) throw new Error('models must be an array')
|
|
579
|
+
const selectedModels = [...new Set(models.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
|
|
580
|
+
const config = getConfig() ?? {}
|
|
581
|
+
const row = providers().find((item) => item.provider === provider)
|
|
582
|
+
if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
|
|
583
|
+
const latest = lastRun?.results?.find((item) => item.provider === provider && item.status === 'ok')
|
|
584
|
+
const cached = normalizeModels(provider, config.modelCatalogs?.[provider])
|
|
585
|
+
const latestCatalog = latest?.availableModels ?? latest?.models
|
|
586
|
+
const source = latestCatalog !== undefined
|
|
587
|
+
? latestCatalog
|
|
588
|
+
: cached.length > 0 ? cached : row.availableModels ?? row.models
|
|
589
|
+
const available = filterModels(normalizeModels(provider, source), policyFor(config, provider))
|
|
590
|
+
const safeAvailable = selectableModels(config.modelLifecycle, provider, available)
|
|
591
|
+
const availableById = new Map(available.map((model) => [model.id, model]))
|
|
592
|
+
const unknown = selectedModels.filter((id) => !availableById.has(id))
|
|
593
|
+
if (unknown.length > 0) throw Object.assign(new Error(`unknown models for ${provider}: ${unknown.join(', ')}`), { code: 'UNKNOWN_MODEL' })
|
|
594
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
595
|
+
|
|
596
|
+
const settings = settingsOf(ctx)
|
|
597
|
+
const section = settings?.get?.(PIAI_NS)
|
|
598
|
+
const descriptor = descriptorOf(settings, PIAI_NS)
|
|
599
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
600
|
+
throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
|
|
601
|
+
}
|
|
602
|
+
const current = normalizeModels(provider, profileOf(settings, provider).models ?? row.models)
|
|
603
|
+
const nextModels = selectedModels.length
|
|
604
|
+
? selectedModels.map((id) => availableById.get(id))
|
|
605
|
+
: (safeAvailable.length > 0 ? safeAvailable : current)
|
|
606
|
+
const previousModelSelections = structuredClone(config.modelSelections ?? {})
|
|
607
|
+
const modelSelections = { ...previousModelSelections, [provider]: selectedModels }
|
|
608
|
+
|
|
609
|
+
// Persist the allowlist first. If the LLM catalog write fails, roll the
|
|
610
|
+
// allowlist back so the two namespaces cannot be left disagreeing.
|
|
611
|
+
await saveConfig({ modelSelections })
|
|
612
|
+
try {
|
|
613
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, nextModels))
|
|
614
|
+
} catch (error) {
|
|
615
|
+
try {
|
|
616
|
+
await saveConfig({ modelSelections: previousModelSelections })
|
|
617
|
+
} catch {
|
|
618
|
+
// Preserve the original, more actionable catalog-write error.
|
|
619
|
+
}
|
|
620
|
+
throw error
|
|
621
|
+
}
|
|
622
|
+
return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return Object.freeze({
|
|
626
|
+
listProviders: providers,
|
|
627
|
+
getProvider: (provider) => providers().find((row) => row.provider === provider) ?? null,
|
|
628
|
+
adapterRegistry: registry,
|
|
629
|
+
registerAdapter: registry.register,
|
|
630
|
+
discover,
|
|
631
|
+
run,
|
|
632
|
+
setModelSelection,
|
|
633
|
+
setModelPolicy,
|
|
634
|
+
listPolicies,
|
|
635
|
+
health,
|
|
636
|
+
tryModel,
|
|
637
|
+
credentialDiagnostics,
|
|
638
|
+
history,
|
|
639
|
+
report: () => structuredClone(lastReport),
|
|
640
|
+
notifications: notificationLedger,
|
|
641
|
+
updateNotification: updateNotificationState,
|
|
642
|
+
rollbackHistory,
|
|
643
|
+
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}) }),
|
|
644
|
+
})
|
|
645
|
+
}
|