@goodandready/dsh-model-sync 0.3.14 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/client.js +121 -13
- package/lib/http.js +30 -4
- package/lib/index.js +9 -1
- package/lib/synchronizer-helpers.js +113 -0
- package/lib/synchronizer-prober.js +50 -0
- package/lib/synchronizer-transfer.js +118 -0
- package/lib/synchronizer.js +31 -264
- package/lib/updater.js +362 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -117,6 +117,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
|
117
117
|
|
|
118
118
|
---
|
|
119
119
|
|
|
120
|
+
## 🚀 Enhancements in v0.4.0
|
|
121
|
+
|
|
122
|
+
- **One-Click In-App Updater**: Check npm registry for updates and trigger one-click upgrades directly within the DSH Settings Card, complete with version diff badges and server restart alerts.
|
|
123
|
+
- **Strict Loopback Security**: All mutating HTTP endpoints (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) now require verified loopback origin and remote socket IP to prevent CSRF / unauthorized network access.
|
|
124
|
+
- **100% Theme Token Compliance**: Eliminated all hardcoded colors and `rgba()` values in client UI in favor of official DSH theme tokens (`--dsw-alias-*`), ensuring perfect dark/light mode rendering.
|
|
125
|
+
- **Modular Architecture**: Decomposed core synchronization logic into specialized modules (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`), significantly enhancing code readability and maintainability.
|
|
126
|
+
- **Clean Packaging**: Excluded development and internal planning artifacts from git and npm distribution, maintaining an ultra-lightweight release bundle (< 70 KiB).
|
|
127
|
+
|
|
120
128
|
## 🚀 Enhancements in v0.3.14
|
|
121
129
|
|
|
122
130
|
- **Language Standard**: Canonical plugin in `en` and `zh`. Runtime Russian translations decoupled into `goodandready/dsh-russian-lang` (#191).
|
package/README.ru.md
CHANGED
|
@@ -94,6 +94,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
97
|
+
## 🚀 Улучшения в версии 0.4.0
|
|
98
|
+
|
|
99
|
+
- **One-Click обновление плагина из настроек**: В карточку настроек плагина добавлен блок обновления с проверкой актуальной версии в npm, цветовым бейджем статуса и кнопкой обновления в один клик.
|
|
100
|
+
- **Защита write-эндпоинтов по Loopback**: Все мутирующие HTTP-маршруты (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) строго проверяют адрес источника (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) и заголовок Origin/Host для предотвращения несанкционированного доступа.
|
|
101
|
+
- **Полная поддержка темы DSH (0 rgba / 0 hex)**: Все стили интерфейса переведены на официальные токены дизайн-системы DSH (`--dsw-alias-*`), обеспечивая нативную интеграцию со светлой и тёмной темами оформления.
|
|
102
|
+
- **Модульная архитектура синхронизатора**: Монолитная логика синхронизации разделена на специализированные модули (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`), что упрощает поддержку и снижает размер основного файла ниже 600 строк.
|
|
103
|
+
- **Чистый дистрибутив пакета**: Исключены внутренние файлы планирования и отладки; размер сжатого npm-пакета составляет менее 70 KiB, все файлы укладываются в лимит 256 KiB.
|
|
104
|
+
|
|
97
105
|
## 🚀 Улучшения в v0.3.13
|
|
98
106
|
|
|
99
107
|
* ⚡ **ETag / 304 Not Modified для фонового опроса UI**:
|
package/README.zh.md
CHANGED
|
@@ -95,6 +95,14 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
|
95
95
|
|
|
96
96
|
---
|
|
97
97
|
|
|
98
|
+
## 🚀 v0.4.0 新特性与增强
|
|
99
|
+
|
|
100
|
+
- **设置卡片一键更新**: 在 DSH 设置卡片中集成自动版本检查,展示当前与最新版本对比徽标,支持一键安全升级并在更新后提示重启。
|
|
101
|
+
- **严格本地环回 (Loopback) 安全保护**: 所有修改状态的 HTTP 路由 (`/apply`, `/policy`, `/clear-cache`, `/updater/update`) 均强制校验本地环回 IP (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) 及请求来源,杜绝外部恶意调用。
|
|
102
|
+
- **100% 原生主题变量适配**: 客户端 UI 全面移除硬编码颜色与 `rgba()` 样式,完全切换至 DSH 官方 CSS 设计令牌 (`--dsw-alias-*`),无缝适配深色与浅色主题。
|
|
103
|
+
- **同步引擎模块化拆分**: 核心同步逻辑已解耦拆分为专项子模块 (`synchronizer-helpers.js`, `synchronizer-transfer.js`, `synchronizer-prober.js`),大幅提升代码可读性与维护性。
|
|
104
|
+
- **轻量分发包清理**: 彻底排除内部计划与调试文件,精简 npm 发布体积至 70 KiB 以下,所有文件均完全符合 256 KiB 上限规范。
|
|
105
|
+
|
|
98
106
|
## 🚀 v0.3.14 新特性与增强
|
|
99
107
|
|
|
100
108
|
- **语言标准**: 插件原生支持 `en` 与 `zh` 双语,俄语本地化已独立解耦至 `goodandready/dsh-russian-lang` 翻译包 (#191)。
|
package/lib/client.js
CHANGED
|
@@ -8,7 +8,7 @@ window.__ModuleLoader__.load({
|
|
|
8
8
|
const CSS = '.dms-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;margin:0;padding:0;overflow:hidden;box-sizing:border-box}' +
|
|
9
9
|
'.dms-card-open{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-border-l2)}' +
|
|
10
10
|
'.dms-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
|
|
11
|
-
'.dms-card-header:hover{background:var(--dsw-alias-interactive-bg-hover,
|
|
11
|
+
'.dms-card-header:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))}' +
|
|
12
12
|
'.dms-card-header:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
|
|
13
13
|
'.dms-card-head-text{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
|
|
14
14
|
'.dms-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
|
|
@@ -17,16 +17,16 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
'.dms-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px;display:flex;flex-direction:column;gap:10px;box-sizing:border-box}' +
|
|
18
18
|
'.dms-wrap{display:flex;flex-direction:column;gap:14px;max-width:760px;box-sizing:border-box;color:var(--dsw-alias-label-primary,inherit)}' +
|
|
19
19
|
'.dms-wrap>p{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}' +
|
|
20
|
-
'.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary
|
|
20
|
+
'.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
|
|
21
21
|
'.dms-toolbar>select{flex:1 1 190px;min-width:190px}' +
|
|
22
22
|
'.dms-toolbar>.dms-actions{flex-basis:100%}' +
|
|
23
23
|
'.dms-toolbar>label{display:inline-flex;align-items:flex-start;gap:7px;font-size:12px;line-height:1.35;color:var(--dsw-alias-label-secondary)}' +
|
|
24
24
|
'.dms-toolbar>select,.dms-editor input,.dms-editor textarea,.dms-input{box-sizing:border-box;min-width:0;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-2,transparent));border:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-l2));border-radius:6px;color:var(--dsw-alias-label-primary);font:inherit;padding:7px 9px}' +
|
|
25
25
|
'.dms-toolbar>label input{accent-color:var(--dsw-alias-brand-primary,var(--dsw-alias-button-info-fill))}' +
|
|
26
26
|
'.dms-actions{display:flex;align-items:center;flex-wrap:wrap;gap:7px}' +
|
|
27
|
-
'.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary
|
|
27
|
+
'.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
|
|
28
28
|
'.dms-btn{margin:0!important;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.35;padding:6px 10px;cursor:pointer;white-space:normal}' +
|
|
29
|
-
'.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2
|
|
29
|
+
'.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2)}' +
|
|
30
30
|
'.dms-btn:disabled{opacity:.45;cursor:default}' +
|
|
31
31
|
'.dms-section>strong{display:block;font-size:13px;font-weight:650}' +
|
|
32
32
|
'.dms-provider-wrap{margin:0 0 7px}' +
|
|
@@ -44,18 +44,18 @@ window.__ModuleLoader__.load({
|
|
|
44
44
|
'.dms-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}' +
|
|
45
45
|
'.dms-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}' +
|
|
46
46
|
'.dms-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
|
|
47
|
-
'.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
48
|
-
'.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
49
|
-
'.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
47
|
+
'.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2))}' +
|
|
48
|
+
'.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warning-secondary,var(--dsw-alias-bg-layer-2))}' +
|
|
49
|
+
'.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2))}' +
|
|
50
50
|
'.dms-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
|
|
51
51
|
'.dms-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
|
|
52
52
|
'.dms-btn-primary{background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;border-color:transparent!important;font-weight:600}' +
|
|
53
53
|
'.dms-btn-primary:hover:not(:disabled){opacity:0.88}' +
|
|
54
|
-
'.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:
|
|
55
|
-
'.dms-btn-danger:hover:not(:disabled){background:
|
|
54
|
+
'.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:var(--dsw-alias-state-error-primary)!important}' +
|
|
55
|
+
'.dms-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2))!important;border-color:var(--dsw-alias-state-error-primary)!important}' +
|
|
56
56
|
'.dms-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
|
|
57
|
-
'.dms-alert-ok{background:
|
|
58
|
-
'.dms-alert-bad{background:
|
|
57
|
+
'.dms-alert-ok{background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-success-primary)}' +
|
|
58
|
+
'.dms-alert-bad{background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-error-primary)}' +
|
|
59
59
|
'.dms-probe-result{padding:2px 6px;border-radius:4px;font-family:monospace;font-size:11px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px}' +
|
|
60
60
|
'@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-section-card,.dms-editor{padding:11px 12px}}'
|
|
61
61
|
|
|
@@ -227,6 +227,17 @@ window.__ModuleLoader__.load({
|
|
|
227
227
|
addAlias: 'Save alias',
|
|
228
228
|
removeAlias: 'Delete alias',
|
|
229
229
|
noAliases: 'No model aliases defined.',
|
|
230
|
+
updaterTitle: 'Plugin Updates',
|
|
231
|
+
checkForUpdates: 'Check for updates',
|
|
232
|
+
checkingUpdates: 'Checking…',
|
|
233
|
+
updateAvailable: 'Update available: ',
|
|
234
|
+
upToDate: 'Up to date',
|
|
235
|
+
updateNow: 'Update now',
|
|
236
|
+
updating: 'Updating…',
|
|
237
|
+
updateSuccess: 'Updated to ',
|
|
238
|
+
restartRequired: ' (restart required)',
|
|
239
|
+
updateFailed: 'Update failed: ',
|
|
240
|
+
checkUpdateFailed: 'Failed to check for updates',
|
|
230
241
|
targetProvider: 'Provider',
|
|
231
242
|
}
|
|
232
243
|
|
|
@@ -347,6 +358,17 @@ window.__ModuleLoader__.load({
|
|
|
347
358
|
addAlias: '保存别名',
|
|
348
359
|
removeAlias: '删除别名',
|
|
349
360
|
noAliases: '未定义任何模型别名。',
|
|
361
|
+
updaterTitle: '插件更新',
|
|
362
|
+
checkForUpdates: '检查更新',
|
|
363
|
+
checkingUpdates: '正在检查…',
|
|
364
|
+
updateAvailable: '发现新版本: ',
|
|
365
|
+
upToDate: '已是最新版本',
|
|
366
|
+
updateNow: '立即更新',
|
|
367
|
+
updating: '正在更新…',
|
|
368
|
+
updateSuccess: '已成功更新至 ',
|
|
369
|
+
restartRequired: ' (需重启 DSH)',
|
|
370
|
+
updateFailed: '更新失败: ',
|
|
371
|
+
checkUpdateFailed: '检查更新失败',
|
|
350
372
|
targetProvider: '服务商',
|
|
351
373
|
}
|
|
352
374
|
|
|
@@ -372,6 +394,84 @@ window.__ModuleLoader__.load({
|
|
|
372
394
|
)
|
|
373
395
|
}
|
|
374
396
|
|
|
397
|
+
|
|
398
|
+
function UpdaterSection({ t, api }) {
|
|
399
|
+
const [updaterStatus, setUpdaterStatus] = React.useState(null)
|
|
400
|
+
const [updaterChecking, setUpdaterChecking] = React.useState(false)
|
|
401
|
+
const [updaterUpdating, setUpdaterUpdating] = React.useState(false)
|
|
402
|
+
const [updaterNotice, setUpdaterNotice] = React.useState('')
|
|
403
|
+
const [updaterError, setUpdaterError] = React.useState('')
|
|
404
|
+
|
|
405
|
+
const checkUpdates = () => {
|
|
406
|
+
setUpdaterChecking(true)
|
|
407
|
+
setUpdaterError('')
|
|
408
|
+
setUpdaterNotice('')
|
|
409
|
+
fetch(`${api}/update`)
|
|
410
|
+
.then((res) => {
|
|
411
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
412
|
+
return res.json()
|
|
413
|
+
})
|
|
414
|
+
.then((data) => {
|
|
415
|
+
setUpdaterStatus(data)
|
|
416
|
+
setUpdaterChecking(false)
|
|
417
|
+
})
|
|
418
|
+
.catch(() => {
|
|
419
|
+
setUpdaterChecking(false)
|
|
420
|
+
setUpdaterError(t.checkUpdateFailed)
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const runUpdate = () => {
|
|
425
|
+
if (updaterUpdating) return
|
|
426
|
+
setUpdaterUpdating(true)
|
|
427
|
+
setUpdaterError('')
|
|
428
|
+
setUpdaterNotice('')
|
|
429
|
+
fetch(`${api}/update`, {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
|
|
432
|
+
})
|
|
433
|
+
.then((res) => res.json())
|
|
434
|
+
.then((resData) => {
|
|
435
|
+
if (resData.error) throw new Error(resData.error.message || resData.error)
|
|
436
|
+
const cur = resData.updatedVersion || (updaterStatus && updaterStatus.latestVersion) || ''
|
|
437
|
+
const nextData = Object.assign({}, updaterStatus, { currentVersion: cur, updateAvailable: false })
|
|
438
|
+
setUpdaterUpdating(false)
|
|
439
|
+
setUpdaterStatus(nextData)
|
|
440
|
+
setUpdaterNotice(t.updateSuccess + cur + (resData.restartRequired ? t.restartRequired : ''))
|
|
441
|
+
})
|
|
442
|
+
.catch((err) => {
|
|
443
|
+
setUpdaterUpdating(false)
|
|
444
|
+
setUpdaterError(t.updateFailed + (err.message || String(err)))
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return React.createElement('div', { style: { marginTop: 8 } },
|
|
449
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
450
|
+
React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)' } },
|
|
451
|
+
updaterStatus ? `v${updaterStatus.currentVersion}` : '',
|
|
452
|
+
updaterStatus && updaterStatus.updateAvailable ? ` → v${updaterStatus.latestVersion}` : ''
|
|
453
|
+
),
|
|
454
|
+
React.createElement('div', null,
|
|
455
|
+
updaterStatus && updaterStatus.updateAvailable
|
|
456
|
+
? React.createElement('button', {
|
|
457
|
+
type: 'button',
|
|
458
|
+
className: 'dms-btn dms-btn-primary',
|
|
459
|
+
disabled: updaterUpdating,
|
|
460
|
+
onClick: runUpdate,
|
|
461
|
+
}, updaterUpdating ? t.updating : t.updateNow)
|
|
462
|
+
: React.createElement('button', {
|
|
463
|
+
type: 'button',
|
|
464
|
+
className: 'dms-btn',
|
|
465
|
+
disabled: updaterChecking,
|
|
466
|
+
onClick: checkUpdates,
|
|
467
|
+
}, updaterChecking ? t.checkingUpdates : (updaterStatus ? t.upToDate : t.checkForUpdates))
|
|
468
|
+
)
|
|
469
|
+
),
|
|
470
|
+
updaterNotice ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-success)', marginTop: 8, fontSize: 12 } }, updaterNotice) : null,
|
|
471
|
+
updaterError ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', marginTop: 8, fontSize: 12 } }, updaterError) : null
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
375
475
|
function ModelSyncSection({ ctx, includeIntro = true }) {
|
|
376
476
|
const locale = useLocale(ctx)
|
|
377
477
|
const t = locale === 'zh' ? zh : en
|
|
@@ -1119,6 +1219,10 @@ window.__ModuleLoader__.load({
|
|
|
1119
1219
|
React.createElement('div', { className: 'dms-section-card dms-section' },
|
|
1120
1220
|
React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.history)),
|
|
1121
1221
|
renderHistory()
|
|
1222
|
+
),
|
|
1223
|
+
React.createElement('div', { className: 'dms-section-card dms-section' },
|
|
1224
|
+
React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.updaterTitle)),
|
|
1225
|
+
React.createElement(UpdaterSection, { t, api: API })
|
|
1122
1226
|
)
|
|
1123
1227
|
)
|
|
1124
1228
|
}
|
|
@@ -1157,12 +1261,16 @@ window.__ModuleLoader__.load({
|
|
|
1157
1261
|
|
|
1158
1262
|
function apply(ctx) {
|
|
1159
1263
|
ctx.effect(() => {
|
|
1160
|
-
try { ctx.locale?.register?.(NS, { en, zh }) } catch {
|
|
1264
|
+
try { ctx.locale?.register?.(NS, { en, zh }) } catch (err) {
|
|
1265
|
+
try { console.debug('[dsh-model-sync] locale register best-effort:', err) } catch { /* console unavailable */ }
|
|
1266
|
+
}
|
|
1161
1267
|
return () => {}
|
|
1162
1268
|
}, 'dsh-model-sync: dictionaries')
|
|
1163
1269
|
try {
|
|
1164
1270
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
|
|
1165
|
-
} catch {
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
try { console.debug('[dsh-model-sync] slots inject best-effort:', err) } catch { /* console unavailable */ }
|
|
1273
|
+
}
|
|
1166
1274
|
}
|
|
1167
1275
|
|
|
1168
1276
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], filterModelsByCapabilities }
|
package/lib/http.js
CHANGED
|
@@ -8,24 +8,50 @@ function json(res, status, body) {
|
|
|
8
8
|
res.end(JSON.stringify(body))
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function
|
|
11
|
+
export function isLoopbackAddress(ip) {
|
|
12
|
+
if (!ip || typeof ip !== 'string') return false
|
|
13
|
+
const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
|
|
14
|
+
if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
|
|
15
|
+
if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function trusted(req, { requireLoopback = false } = {}) {
|
|
20
|
+
const remoteAddress = req?.socket?.remoteAddress
|
|
21
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
22
|
+
if (!isLoopbackAddress(remoteAddress)) return false
|
|
23
|
+
}
|
|
12
24
|
const fetchSite = req.headers?.['sec-fetch-site']
|
|
13
25
|
if (fetchSite === 'cross-site') return false
|
|
14
26
|
const host = req.headers?.host
|
|
15
27
|
const origin = req.headers?.origin
|
|
16
28
|
if (origin) {
|
|
17
29
|
if (!host) return false
|
|
18
|
-
try {
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(origin)
|
|
32
|
+
if (url.host !== host) return false
|
|
33
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
34
|
+
if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
|
|
35
|
+
}
|
|
36
|
+
} catch { return false }
|
|
19
37
|
}
|
|
20
38
|
const referer = req.headers?.referer
|
|
21
39
|
if (referer) {
|
|
22
40
|
if (!host) return false
|
|
23
|
-
try {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(referer)
|
|
43
|
+
if (url.host !== host) return false
|
|
44
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
45
|
+
if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
|
|
46
|
+
}
|
|
47
|
+
} catch { return false }
|
|
24
48
|
}
|
|
25
49
|
// If sec-fetch-site is same-origin or same-site, allow
|
|
26
50
|
if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
|
|
27
51
|
// If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
|
|
28
52
|
if (req.headers?.['sec-fetch-mode'] && !host) return false
|
|
53
|
+
// For POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
|
|
54
|
+
if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
|
|
29
55
|
return true
|
|
30
56
|
}
|
|
31
57
|
|
|
@@ -188,7 +214,7 @@ export function normalizeSelectionRequest(body) {
|
|
|
188
214
|
|
|
189
215
|
export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
|
|
190
216
|
const makeHandler = (route) => async (req, res) => {
|
|
191
|
-
if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
|
|
217
|
+
if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
192
218
|
if (route === 'status') {
|
|
193
219
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
194
220
|
const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createProviderService } from './provider-service.js'
|
|
|
3
3
|
import { createModelSynchronizer } from './synchronizer.js'
|
|
4
4
|
import { createSyncScheduler } from './scheduler.js'
|
|
5
5
|
import { registerHttpApi } from './http.js'
|
|
6
|
+
import { registerPluginUpdater } from './updater.js'
|
|
6
7
|
|
|
7
8
|
export const name = '@goodandready/dsh-model-sync'
|
|
8
9
|
export const inject = ['llm', 'settings', 'webServer', 'credentials']
|
|
@@ -143,7 +144,9 @@ export function apply(ctx, config) {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
let settings
|
|
146
|
-
try { settings = ctx.get('settings') } catch {
|
|
147
|
+
try { settings = ctx.get('settings') } catch (e) {
|
|
148
|
+
if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('[dsh-model-sync] settings service not immediately available:', e)
|
|
149
|
+
}
|
|
147
150
|
if (!bindSettings(settings, ctx)) {
|
|
148
151
|
ctx.inject(['settings'], (sctx) => {
|
|
149
152
|
bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
|
|
@@ -163,6 +166,11 @@ export function apply(ctx, config) {
|
|
|
163
166
|
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
|
164
167
|
}
|
|
165
168
|
}, 'dsh-model-sync: http api')
|
|
169
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
170
|
+
packageName: name,
|
|
171
|
+
endpoint: '/api/dsh-model-sync/update',
|
|
172
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
173
|
+
}), 'dsh-model-sync: plugin updater')
|
|
166
174
|
ctx.effect(() => {
|
|
167
175
|
if (!liveConfig.enabled) return () => {}
|
|
168
176
|
ctx.provide('modelSync', service)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { statusOfError } from './reliability.js'
|
|
2
|
+
|
|
3
|
+
export const PIAI_NS = 'llm-pi-ai'
|
|
4
|
+
export const DEFAULT_RELIABILITY = Object.freeze({
|
|
5
|
+
timeoutMs: 15000,
|
|
6
|
+
retryAttempts: 3,
|
|
7
|
+
retryBaseDelayMs: 250,
|
|
8
|
+
retryMaxDelayMs: 4000,
|
|
9
|
+
concurrency: 4,
|
|
10
|
+
circuitBreakerFailures: 3,
|
|
11
|
+
circuitBreakerCooldownMs: 300000,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export function boundedInteger(value, fallback, minimum, maximum) {
|
|
15
|
+
const number = Number(value)
|
|
16
|
+
if (!Number.isFinite(number)) return fallback
|
|
17
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(number)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reliabilityOptions(config, options) {
|
|
21
|
+
const source = { ...config, ...options }
|
|
22
|
+
const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
|
|
23
|
+
const cooldownMs = source.circuitBreakerCooldownMs !== undefined
|
|
24
|
+
? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
25
|
+
: boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
|
|
26
|
+
return {
|
|
27
|
+
timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
|
|
28
|
+
retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
|
|
29
|
+
retryBaseDelayMs: baseDelay,
|
|
30
|
+
retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
|
|
31
|
+
concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
|
|
32
|
+
circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
|
|
33
|
+
circuitBreakerCooldownMs: cooldownMs,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function settingsOf(ctx) {
|
|
38
|
+
try { return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings) } catch { return ctx.settings }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function llmOf(ctx) {
|
|
42
|
+
try {
|
|
43
|
+
const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
|
|
44
|
+
return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
|
|
45
|
+
} catch {
|
|
46
|
+
return ctx.llm
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function descriptorOf(settings, ns) {
|
|
51
|
+
try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
|
|
52
|
+
catch { return undefined }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function profileOf(settings, provider) {
|
|
56
|
+
try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
|
|
57
|
+
catch { return {} }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function errorMessage(error) {
|
|
61
|
+
return error instanceof Error ? error.message : String(error)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function diagnosticMessage(error) {
|
|
65
|
+
return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function adapterErrorType(error) {
|
|
69
|
+
if (error?.adapterCode === 'auth') return 'auth'
|
|
70
|
+
if (error?.adapterCode === 'rate-limit') return 'rate-limit'
|
|
71
|
+
if (error?.adapterCode === 'schema') return 'schema'
|
|
72
|
+
if (error?.adapterCode === 'endpoint') return 'endpoint'
|
|
73
|
+
if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
|
|
74
|
+
const status = statusOfError(error)
|
|
75
|
+
if (status === 401 || status === 403) return 'auth'
|
|
76
|
+
if (status === 429) return 'rate-limit'
|
|
77
|
+
if (status) return 'http'
|
|
78
|
+
return 'endpoint'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
|
|
82
|
+
let lastError
|
|
83
|
+
for (let i = 0; i < attempts; i++) {
|
|
84
|
+
const section = settings?.get?.(ns)
|
|
85
|
+
const descriptor = descriptorOf(settings, ns)
|
|
86
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
87
|
+
throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
|
|
88
|
+
}
|
|
89
|
+
const next = patchFn(structuredClone(section))
|
|
90
|
+
try {
|
|
91
|
+
await settings.replace(ns, next, descriptor.revision)
|
|
92
|
+
return next
|
|
93
|
+
} catch (err) {
|
|
94
|
+
lastError = err
|
|
95
|
+
const isConflict = err?.code === 'SETTINGS_CONFLICT' || err?.status === 409 || /conflict|revision/i.test(err?.message || '')
|
|
96
|
+
if (isConflict && i < attempts - 1) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw lastError
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function rotationRefs(settings, provider) {
|
|
106
|
+
try {
|
|
107
|
+
const rows = settings?.get?.('dsh-key-rotation')?.providers
|
|
108
|
+
if (!Array.isArray(rows)) return []
|
|
109
|
+
return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function createModelProber({ health }) {
|
|
2
|
+
async function tryModel({ provider, model } = {}) {
|
|
3
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
4
|
+
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
5
|
+
const start = Date.now()
|
|
6
|
+
const result = await health({ provider })
|
|
7
|
+
const entry = result?.results?.find((r) => r.provider === provider)
|
|
8
|
+
return {
|
|
9
|
+
provider,
|
|
10
|
+
model,
|
|
11
|
+
ok: entry?.status === 'ok',
|
|
12
|
+
latencyMs: Date.now() - start,
|
|
13
|
+
status: entry?.status ?? 'unknown',
|
|
14
|
+
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function batchTryModels({ provider, models } = {}) {
|
|
19
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
20
|
+
if (!Array.isArray(models)) throw new Error('models must be an array')
|
|
21
|
+
const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
|
|
22
|
+
const results = []
|
|
23
|
+
const concurrency = 3
|
|
24
|
+
for (let i = 0; i < list.length; i += concurrency) {
|
|
25
|
+
const chunk = list.slice(i, i + concurrency)
|
|
26
|
+
const chunkResults = await Promise.all(
|
|
27
|
+
chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
|
|
28
|
+
provider,
|
|
29
|
+
model,
|
|
30
|
+
ok: false,
|
|
31
|
+
latencyMs: 0,
|
|
32
|
+
status: 'error',
|
|
33
|
+
error: err instanceof Error ? err.message : String(err)
|
|
34
|
+
})))
|
|
35
|
+
)
|
|
36
|
+
results.push(...chunkResults)
|
|
37
|
+
}
|
|
38
|
+
const reachable = results.filter((r) => r.ok).length
|
|
39
|
+
return {
|
|
40
|
+
provider,
|
|
41
|
+
results,
|
|
42
|
+
summary: { total: results.length, reachable, unreachable: results.length - reachable }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
tryModel,
|
|
48
|
+
batchTryModels,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export function createConfigTransfer({ getConfig = () => ({}), saveConfig = async () => {}, listProviders = () => [], validatePolicy } = {}) {
|
|
2
|
+
function exportConfig() {
|
|
3
|
+
const config = getConfig() ?? {}
|
|
4
|
+
const pmap = {}
|
|
5
|
+
for (const p of listProviders()) {
|
|
6
|
+
if (p.configured) {
|
|
7
|
+
pmap[p.provider] = {
|
|
8
|
+
policy: config.modelPolicies?.[p.provider] ?? {},
|
|
9
|
+
selectedModels: config.modelSelections?.[p.provider] ?? []
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
version: '1.0',
|
|
15
|
+
exportedAt: new Date().toISOString(),
|
|
16
|
+
plugin: '@goodandready/dsh-model-sync',
|
|
17
|
+
providers: pmap,
|
|
18
|
+
aliases: config.aliases ?? {},
|
|
19
|
+
scheduler: {
|
|
20
|
+
scheduleEnabled: config.scheduleEnabled ?? false,
|
|
21
|
+
intervalMinutes: config.intervalMinutes ?? 60,
|
|
22
|
+
autoApply: config.autoApply ?? false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function importConfig(data) {
|
|
28
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
29
|
+
throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
|
|
30
|
+
}
|
|
31
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
32
|
+
const config = getConfig() ?? {}
|
|
33
|
+
const importedProviders = []
|
|
34
|
+
const importedAliases = []
|
|
35
|
+
const patch = {}
|
|
36
|
+
|
|
37
|
+
if (data.providers && typeof data.providers === 'object') {
|
|
38
|
+
const currentPolicies = structuredClone(config.modelPolicies ?? {})
|
|
39
|
+
const currentSelections = structuredClone(config.modelSelections ?? {})
|
|
40
|
+
for (const [pname, pdata] of Object.entries(data.providers)) {
|
|
41
|
+
if (pdata && typeof pdata === 'object') {
|
|
42
|
+
if (pdata.policy) {
|
|
43
|
+
currentPolicies[pname] = typeof validatePolicy === 'function' ? validatePolicy(pdata.policy) : pdata.policy
|
|
44
|
+
importedProviders.push(pname)
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(pdata.selectedModels)) {
|
|
47
|
+
currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
|
|
48
|
+
importedProviders.push(pname)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
patch.modelPolicies = currentPolicies
|
|
53
|
+
patch.modelSelections = currentSelections
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (data.aliases && typeof data.aliases === 'object') {
|
|
57
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
58
|
+
for (const [k, v] of Object.entries(data.aliases)) {
|
|
59
|
+
if (typeof k === 'string' && /^[a-zA-Z0-9_\-.]{1,64}$/.test(k.trim()) && v && typeof v === 'object' && typeof v.provider === 'string' && typeof v.model === 'string') {
|
|
60
|
+
currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
|
|
61
|
+
importedAliases.push(k.trim())
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
patch.aliases = currentAliases
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (data.scheduler && typeof data.scheduler === 'object') {
|
|
68
|
+
if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
|
|
69
|
+
if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
|
|
70
|
+
if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await saveConfig(patch)
|
|
74
|
+
return {
|
|
75
|
+
success: true,
|
|
76
|
+
importedProviders: [...new Set(importedProviders)],
|
|
77
|
+
importedAliases: [...new Set(importedAliases)]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getAliases() {
|
|
82
|
+
const config = getConfig() ?? {}
|
|
83
|
+
return config.aliases ?? {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function setAlias({ alias, provider, model } = {}) {
|
|
87
|
+
if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
|
|
88
|
+
throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
|
|
89
|
+
}
|
|
90
|
+
const trimmedAlias = alias.trim()
|
|
91
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
92
|
+
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
93
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
94
|
+
const config = getConfig() ?? {}
|
|
95
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
96
|
+
currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
|
|
97
|
+
await saveConfig({ aliases: currentAliases })
|
|
98
|
+
return { alias: trimmedAlias, provider, model, aliases: currentAliases }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function deleteAlias({ alias } = {}) {
|
|
102
|
+
if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
|
|
103
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
104
|
+
const config = getConfig() ?? {}
|
|
105
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
106
|
+
delete currentAliases[alias.trim()]
|
|
107
|
+
await saveConfig({ aliases: currentAliases })
|
|
108
|
+
return { alias: alias.trim(), deleted: true, aliases: currentAliases }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
exportConfig,
|
|
113
|
+
importConfig,
|
|
114
|
+
getAliases,
|
|
115
|
+
setAlias,
|
|
116
|
+
deleteAlias,
|
|
117
|
+
}
|
|
118
|
+
}
|
package/lib/synchronizer.js
CHANGED
|
@@ -9,117 +9,23 @@ import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './
|
|
|
9
9
|
import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
|
|
10
10
|
import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}
|
|
12
|
+
import {
|
|
13
|
+
PIAI_NS,
|
|
14
|
+
DEFAULT_RELIABILITY,
|
|
15
|
+
boundedInteger,
|
|
16
|
+
reliabilityOptions,
|
|
17
|
+
settingsOf,
|
|
18
|
+
llmOf,
|
|
19
|
+
descriptorOf,
|
|
20
|
+
profileOf,
|
|
21
|
+
errorMessage,
|
|
22
|
+
diagnosticMessage,
|
|
23
|
+
adapterErrorType,
|
|
24
|
+
replaceWithRetry,
|
|
25
|
+
rotationRefs,
|
|
26
|
+
} from './synchronizer-helpers.js'
|
|
27
|
+
import { createConfigTransfer } from './synchronizer-transfer.js'
|
|
28
|
+
import { createModelProber } from './synchronizer-prober.js'
|
|
123
29
|
|
|
124
30
|
export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
|
|
125
31
|
const resolveCredential = createCredentialResolver(ctx)
|
|
@@ -531,50 +437,9 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
531
437
|
return transientNotifications.find((row) => row.id === id)
|
|
532
438
|
}
|
|
533
439
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const start = Date.now()
|
|
538
|
-
// Use health as lightweight probe; latency is the signal
|
|
539
|
-
const result = await health({ provider })
|
|
540
|
-
const entry = result?.results?.find((r) => r.provider === provider)
|
|
541
|
-
return {
|
|
542
|
-
provider,
|
|
543
|
-
model,
|
|
544
|
-
ok: entry?.status === 'ok',
|
|
545
|
-
latencyMs: Date.now() - start,
|
|
546
|
-
status: entry?.status ?? 'unknown',
|
|
547
|
-
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
async function batchTryModels({ provider, models } = {}) {
|
|
551
|
-
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
552
|
-
if (!Array.isArray(models)) throw new Error('models must be an array')
|
|
553
|
-
const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
|
|
554
|
-
const results = []
|
|
555
|
-
const concurrency = 3
|
|
556
|
-
for (let i = 0; i < list.length; i += concurrency) {
|
|
557
|
-
const chunk = list.slice(i, i + concurrency)
|
|
558
|
-
const chunkResults = await Promise.all(
|
|
559
|
-
chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
|
|
560
|
-
provider,
|
|
561
|
-
model,
|
|
562
|
-
ok: false,
|
|
563
|
-
latencyMs: 0,
|
|
564
|
-
status: 'error',
|
|
565
|
-
error: err instanceof Error ? err.message : String(err)
|
|
566
|
-
})))
|
|
567
|
-
)
|
|
568
|
-
results.push(...chunkResults)
|
|
569
|
-
}
|
|
570
|
-
const reachable = results.filter((r) => r.ok).length
|
|
571
|
-
return {
|
|
572
|
-
provider,
|
|
573
|
-
results,
|
|
574
|
-
summary: { total: results.length, reachable, unreachable: results.length - reachable }
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
440
|
+
const prober = createModelProber({ health })
|
|
441
|
+
const tryModel = prober.tryModel
|
|
442
|
+
const batchTryModels = prober.batchTryModels
|
|
578
443
|
|
|
579
444
|
async function rollbackHistory({ historyId, provider } = {}) {
|
|
580
445
|
if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
|
|
@@ -660,115 +525,17 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
660
525
|
}
|
|
661
526
|
return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
|
|
662
527
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
return {
|
|
675
|
-
version: '1.0',
|
|
676
|
-
exportedAt: new Date().toISOString(),
|
|
677
|
-
plugin: '@goodandready/dsh-model-sync',
|
|
678
|
-
providers: pmap,
|
|
679
|
-
aliases: config.aliases ?? {},
|
|
680
|
-
scheduler: {
|
|
681
|
-
scheduleEnabled: config.scheduleEnabled ?? false,
|
|
682
|
-
intervalMinutes: config.intervalMinutes ?? 60,
|
|
683
|
-
autoApply: config.autoApply ?? false
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
async function importConfig(data) {
|
|
689
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
690
|
-
throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
|
|
691
|
-
}
|
|
692
|
-
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
693
|
-
const config = getConfig() ?? {}
|
|
694
|
-
const importedProviders = []
|
|
695
|
-
const importedAliases = []
|
|
696
|
-
const patch = {}
|
|
697
|
-
|
|
698
|
-
if (data.providers && typeof data.providers === 'object') {
|
|
699
|
-
const currentPolicies = structuredClone(config.modelPolicies ?? {})
|
|
700
|
-
const currentSelections = structuredClone(config.modelSelections ?? {})
|
|
701
|
-
for (const [pname, pdata] of Object.entries(data.providers)) {
|
|
702
|
-
if (pdata && typeof pdata === 'object') {
|
|
703
|
-
if (pdata.policy) {
|
|
704
|
-
currentPolicies[pname] = validatePolicy(pdata.policy)
|
|
705
|
-
importedProviders.push(pname)
|
|
706
|
-
}
|
|
707
|
-
if (Array.isArray(pdata.selectedModels)) {
|
|
708
|
-
currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
|
|
709
|
-
importedProviders.push(pname)
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
patch.modelPolicies = currentPolicies
|
|
714
|
-
patch.modelSelections = currentSelections
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
if (data.aliases && typeof data.aliases === 'object') {
|
|
718
|
-
const currentAliases = structuredClone(config.aliases ?? {})
|
|
719
|
-
for (const [k, v] of Object.entries(data.aliases)) {
|
|
720
|
-
if (typeof k === 'string' && /^[a-zA-Z0-9_\-.]{1,64}$/.test(k.trim()) && v && typeof v === 'object' && typeof v.provider === 'string' && typeof v.model === 'string') {
|
|
721
|
-
currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
|
|
722
|
-
importedAliases.push(k.trim())
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
patch.aliases = currentAliases
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (data.scheduler && typeof data.scheduler === 'object') {
|
|
729
|
-
if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
|
|
730
|
-
if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
|
|
731
|
-
if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
await saveConfig(patch)
|
|
735
|
-
return {
|
|
736
|
-
success: true,
|
|
737
|
-
importedProviders: [...new Set(importedProviders)],
|
|
738
|
-
importedAliases: [...new Set(importedAliases)]
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function getAliases() {
|
|
743
|
-
const config = getConfig() ?? {}
|
|
744
|
-
return config.aliases ?? {}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function setAlias({ alias, provider, model } = {}) {
|
|
748
|
-
if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
|
|
749
|
-
throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
|
|
750
|
-
}
|
|
751
|
-
const trimmedAlias = alias.trim()
|
|
752
|
-
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
753
|
-
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
754
|
-
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
755
|
-
const config = getConfig() ?? {}
|
|
756
|
-
const currentAliases = structuredClone(config.aliases ?? {})
|
|
757
|
-
currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
|
|
758
|
-
await saveConfig({ aliases: currentAliases })
|
|
759
|
-
return { alias: trimmedAlias, provider, model, aliases: currentAliases }
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
async function deleteAlias({ alias } = {}) {
|
|
763
|
-
if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
|
|
764
|
-
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
765
|
-
const config = getConfig() ?? {}
|
|
766
|
-
const currentAliases = structuredClone(config.aliases ?? {})
|
|
767
|
-
delete currentAliases[alias.trim()]
|
|
768
|
-
await saveConfig({ aliases: currentAliases })
|
|
769
|
-
return { alias: alias.trim(), deleted: true, aliases: currentAliases }
|
|
770
|
-
}
|
|
771
|
-
|
|
528
|
+
const transfer = createConfigTransfer({
|
|
529
|
+
getConfig,
|
|
530
|
+
saveConfig,
|
|
531
|
+
listProviders: providers,
|
|
532
|
+
validatePolicy,
|
|
533
|
+
})
|
|
534
|
+
const exportConfig = transfer.exportConfig
|
|
535
|
+
const importConfig = transfer.importConfig
|
|
536
|
+
const getAliases = transfer.getAliases
|
|
537
|
+
const setAlias = transfer.setAlias
|
|
538
|
+
const deleteAlias = transfer.deleteAlias
|
|
772
539
|
|
|
773
540
|
return Object.freeze({
|
|
774
541
|
listProviders: providers,
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host-side one-click updater for @goodandready/dsh-model-sync.
|
|
10
|
+
* Implements the unified DSH plugin updater specification with loopback verification.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
14
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
15
|
+
const VERSION_CACHE_MS = 5 * 60_000
|
|
16
|
+
let latestCache = undefined
|
|
17
|
+
|
|
18
|
+
function header(request, name) {
|
|
19
|
+
const value = request?.headers?.[name]
|
|
20
|
+
return Array.isArray(value) ? value[0] : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isLoopback(value) {
|
|
24
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
25
|
+
return (
|
|
26
|
+
address === 'localhost' ||
|
|
27
|
+
address === 'localhost.' ||
|
|
28
|
+
address === '::1' ||
|
|
29
|
+
address?.startsWith('127.') === true ||
|
|
30
|
+
address?.startsWith('::ffff:127.') === true
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isTrustedUpdateRequest(request) {
|
|
35
|
+
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
36
|
+
const remote = request.socket?.remoteAddress
|
|
37
|
+
if (!isLoopback(remote)) return false
|
|
38
|
+
const site = header(request, 'sec-fetch-site')
|
|
39
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
40
|
+
const origin = header(request, 'origin')
|
|
41
|
+
const host = header(request, 'host')
|
|
42
|
+
if (origin === undefined || host === undefined) return false
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(origin)
|
|
45
|
+
return (
|
|
46
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
47
|
+
isLoopback(url.hostname) &&
|
|
48
|
+
url.host === host
|
|
49
|
+
)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// bestEffort URL parse
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validProfileName(value) {
|
|
57
|
+
return (
|
|
58
|
+
typeof value === 'string' &&
|
|
59
|
+
value !== '' &&
|
|
60
|
+
value !== '.' &&
|
|
61
|
+
value !== '..' &&
|
|
62
|
+
!value.includes('/') &&
|
|
63
|
+
!value.includes('\\') &&
|
|
64
|
+
!/[\0-\x1f\x7f]/.test(value)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function profileNameFromArgv(argv) {
|
|
69
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
70
|
+
const arg = argv[index]
|
|
71
|
+
if (arg === '--profile' || arg === '-p') {
|
|
72
|
+
const next = argv[index + 1]
|
|
73
|
+
if (validProfileName(next)) return next
|
|
74
|
+
}
|
|
75
|
+
if (typeof arg === 'string' && arg.startsWith('--profile=')) {
|
|
76
|
+
const value = arg.slice('--profile='.length)
|
|
77
|
+
if (validProfileName(value)) return value
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findDshCliEntry() {
|
|
84
|
+
const entry = process.argv[1]
|
|
85
|
+
if (typeof entry !== 'string' || entry === '') return undefined
|
|
86
|
+
const resolved = entry.startsWith('file:') ? fileURLToPath(entry) : resolve(process.cwd(), entry)
|
|
87
|
+
if (!existsSync(resolved)) return undefined
|
|
88
|
+
for (let directory = dirname(resolved); ; directory = dirname(directory)) {
|
|
89
|
+
const manifestPath = resolve(directory, 'package.json')
|
|
90
|
+
if (existsSync(manifestPath)) {
|
|
91
|
+
try {
|
|
92
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
93
|
+
const bin =
|
|
94
|
+
typeof manifest.bin === 'string'
|
|
95
|
+
? manifest.bin
|
|
96
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
97
|
+
? manifest.bin.dsh
|
|
98
|
+
: undefined
|
|
99
|
+
if (
|
|
100
|
+
manifest.name === '@deepseek-ai/dsh' &&
|
|
101
|
+
typeof bin === 'string' &&
|
|
102
|
+
!isAbsolute(bin) &&
|
|
103
|
+
resolve(directory, bin) === resolve(resolved)
|
|
104
|
+
) {
|
|
105
|
+
return resolved
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// bestEffort manifest parse
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(directory)
|
|
112
|
+
if (parent === directory) return undefined
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function runtime() {
|
|
117
|
+
const profileDir = resolve(
|
|
118
|
+
process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
|
|
119
|
+
)
|
|
120
|
+
const selected = profileNameFromArgv(process.argv)
|
|
121
|
+
const profileName = validProfileName(selected)
|
|
122
|
+
? selected
|
|
123
|
+
: validProfileName(basename(profileDir))
|
|
124
|
+
? basename(profileDir)
|
|
125
|
+
: 'web'
|
|
126
|
+
const cliEntry = findDshCliEntry()
|
|
127
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseSemver(value) {
|
|
131
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? '').trim())
|
|
132
|
+
if (match === null) return undefined
|
|
133
|
+
return {
|
|
134
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
135
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function comparePrerelease(left, right) {
|
|
140
|
+
if (left.length === 0 && right.length === 0) return 0
|
|
141
|
+
if (left.length === 0) return 1
|
|
142
|
+
if (right.length === 0) return -1
|
|
143
|
+
const length = Math.max(left.length, right.length)
|
|
144
|
+
for (let index = 0; index < length; index += 1) {
|
|
145
|
+
const a = left[index]
|
|
146
|
+
const b = right[index]
|
|
147
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
|
|
148
|
+
if (a === b) continue
|
|
149
|
+
const aNumeric = /^\d+$/.test(a)
|
|
150
|
+
const bNumeric = /^\d+$/.test(b)
|
|
151
|
+
if (aNumeric && bNumeric) {
|
|
152
|
+
const aNumber = BigInt(a)
|
|
153
|
+
const bNumber = BigInt(b)
|
|
154
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
|
|
158
|
+
return a > b ? 1 : -1
|
|
159
|
+
}
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
164
|
+
const current = parseSemver(currentValue)
|
|
165
|
+
const candidate = parseSemver(candidateValue)
|
|
166
|
+
if (current === undefined || candidate === undefined) return false
|
|
167
|
+
for (let index = 0; index < 3; index += 1) {
|
|
168
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
|
|
169
|
+
}
|
|
170
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function resetLatestCache() {
|
|
174
|
+
latestCache = undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
|
|
178
|
+
if (
|
|
179
|
+
latestCache?.packageName === packageName &&
|
|
180
|
+
latestCache.registry === registry &&
|
|
181
|
+
Date.now() < latestCache.expiresAt
|
|
182
|
+
) {
|
|
183
|
+
return latestCache.version
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
187
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
|
|
188
|
+
})
|
|
189
|
+
if (!response.ok) return undefined
|
|
190
|
+
const value = await response.json()
|
|
191
|
+
if (typeof value?.version !== 'string' || value.version === '') return undefined
|
|
192
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
|
|
193
|
+
return value.version
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// bestEffort network check
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function currentVersion(manifestUrl) {
|
|
201
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
|
|
202
|
+
if (typeof value?.version !== 'string' || value.version === '') {
|
|
203
|
+
throw new Error('Cannot read current plugin version.')
|
|
204
|
+
}
|
|
205
|
+
return value.version
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function status(options, target) {
|
|
209
|
+
const current = await currentVersion(options.manifestUrl)
|
|
210
|
+
let latest = undefined
|
|
211
|
+
if (typeof options.getLatestVersion === 'function') {
|
|
212
|
+
latest = await options.getLatestVersion()
|
|
213
|
+
} else if (options.latestVersion !== undefined) {
|
|
214
|
+
latest = options.latestVersion
|
|
215
|
+
} else {
|
|
216
|
+
latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
packageName: options.packageName,
|
|
220
|
+
currentVersion: current,
|
|
221
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
222
|
+
latestCheckFailed: latest === undefined,
|
|
223
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
224
|
+
profileName: target.profileName,
|
|
225
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function installExact(target, packageSpec, options = {}) {
|
|
230
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
231
|
+
await new Promise((resolvePromise, reject) => {
|
|
232
|
+
const child = spawn(
|
|
233
|
+
process.execPath,
|
|
234
|
+
[
|
|
235
|
+
target.cliEntry,
|
|
236
|
+
'plugin',
|
|
237
|
+
'--profile',
|
|
238
|
+
target.profileName,
|
|
239
|
+
'add',
|
|
240
|
+
'--config.minimumReleaseAge=0',
|
|
241
|
+
packageSpec,
|
|
242
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
243
|
+
],
|
|
244
|
+
{
|
|
245
|
+
cwd: target.profileDir,
|
|
246
|
+
windowsHide: true,
|
|
247
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
248
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
let detail = ''
|
|
252
|
+
child.stdout?.on('data', (chunk) => {
|
|
253
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
254
|
+
})
|
|
255
|
+
child.stderr?.on('data', (chunk) => {
|
|
256
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
257
|
+
})
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
child.kill()
|
|
260
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'))
|
|
261
|
+
}, UPDATE_TIMEOUT_MS)
|
|
262
|
+
child.once('error', (error) => {
|
|
263
|
+
clearTimeout(timer)
|
|
264
|
+
reject(error)
|
|
265
|
+
})
|
|
266
|
+
child.once('exit', (code) => {
|
|
267
|
+
clearTimeout(timer)
|
|
268
|
+
if (code === 0) resolvePromise()
|
|
269
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function json(response, statusCode, value) {
|
|
275
|
+
response.writeHead(statusCode, {
|
|
276
|
+
'content-type': 'application/json; charset=utf-8',
|
|
277
|
+
'cache-control': 'no-store',
|
|
278
|
+
})
|
|
279
|
+
response.end(JSON.stringify(value))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function registerPluginUpdater(ctx, options = {}) {
|
|
283
|
+
let installing = false
|
|
284
|
+
if (typeof ctx?.webServer?.register !== 'function') {
|
|
285
|
+
if (typeof ctx?.logger?.warn === 'function') {
|
|
286
|
+
ctx.logger.warn('[dsh-model-sync] webServer service unavailable, updater skipped')
|
|
287
|
+
}
|
|
288
|
+
return () => {}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const packageName = options.packageName ?? '@goodandready/dsh-model-sync'
|
|
292
|
+
const endpoints = Array.isArray(options.endpoints)
|
|
293
|
+
? options.endpoints
|
|
294
|
+
: [options.endpoint ?? '/api/dsh-model-sync/update', '/dsh-model-sync/update']
|
|
295
|
+
const uniqueEndpoints = [...new Set(endpoints)]
|
|
296
|
+
|
|
297
|
+
const handler = async (request, response) => {
|
|
298
|
+
try {
|
|
299
|
+
const target = runtime()
|
|
300
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
301
|
+
const payload = await status({ ...options, packageName }, target)
|
|
302
|
+
response.writeHead(200, {
|
|
303
|
+
'content-type': 'application/json; charset=utf-8',
|
|
304
|
+
'cache-control': 'no-store',
|
|
305
|
+
})
|
|
306
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (request.method !== 'POST') {
|
|
310
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' })
|
|
311
|
+
response.end()
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
315
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (installing) {
|
|
319
|
+
json(response, 409, { error: 'This plugin is already updating.' })
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
installing = true
|
|
323
|
+
try {
|
|
324
|
+
const before = await status({ ...options, packageName }, target)
|
|
325
|
+
if (before.latestVersion === undefined) {
|
|
326
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (!before.updateAvailable) {
|
|
330
|
+
json(response, 200, before)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const doInstall = options.installExact ?? installExact
|
|
334
|
+
await doInstall(target, `${packageName}@${before.latestVersion}`, options)
|
|
335
|
+
json(response, 200, {
|
|
336
|
+
...before,
|
|
337
|
+
updatedVersion: before.latestVersion,
|
|
338
|
+
restartRequired: true,
|
|
339
|
+
})
|
|
340
|
+
} finally {
|
|
341
|
+
installing = false
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (typeof ctx?.logger?.warn === 'function') {
|
|
345
|
+
ctx.logger.warn(`[dsh-model-sync] plugin updater failed: ${String(error)}`)
|
|
346
|
+
}
|
|
347
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' })
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const disposers = uniqueEndpoints.map((path) =>
|
|
352
|
+
ctx.webServer.register({ kind: 'exact', path, handler })
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return () => {
|
|
356
|
+
for (const dispose of disposers) {
|
|
357
|
+
if (typeof dispose === 'function') {
|
|
358
|
+
try { dispose() } catch (err) { /* bestEffort cleanup */ }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-model-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
},
|
|
39
39
|
"client": {
|
|
40
40
|
"platform": "web",
|
|
41
|
-
"inject": [
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-locale",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
45
|
+
]
|
|
42
46
|
}
|
|
43
47
|
},
|
|
44
48
|
"scripts": {
|