@goodandready/dsh-model-sync 0.4.8 → 0.5.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 +3 -0
- package/README.ru.md +3 -0
- package/README.zh.md +3 -0
- package/lib/alias-resolver.js +213 -0
- package/lib/client.js +181 -13
- package/lib/diff-notifier.js +196 -0
- package/lib/http.js +23 -0
- package/lib/index.js +12 -2
- package/lib/models.js +2 -1
- package/lib/pricing-database.js +138 -0
- package/lib/synchronizer-transfer.js +48 -11
- package/lib/synchronizer.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ Key Features
|
|
80
80
|
|
|
81
|
+
* 💰 **Built-in Reference Pricing Database**: Canonical token pricing database automatically enriches models for OpenAI, Anthropic, Groq, Mistral, xAI, and DeepSeek, enabling cost-limit filtering and `@cheap` auto-resolution even when upstream APIs omit pricing metadata.
|
|
82
|
+
* 🎯 **Dynamic Smart Virtual Aliases & Auto-Fallback**: Route requests to virtual aliases like `@fast`, `@cheap`, and `@best-code`, with automatic fallback chains when circuit breakers open or health probes fail.
|
|
83
|
+
* 🔔 **External Diff Webhooks & Event Dispatching**: Real-time webhook notifications with HMAC-SHA256 signature verification and Cordis `model-sync/diff` events when models are added, deprecated, or prices change.
|
|
81
84
|
* ⚡ **High-Performance Policy Filtering**: Fast-path evaluation in `filterModels` bypasses regular expressions and array filtering when no filtering policy is configured, guaranteeing zero-overhead discovery runs.
|
|
82
85
|
* 🔄 **One-Click In-App Updates**: Built-in update checker in Settings with automatic npm cache bypass on demand (`?fresh=1` / `Cache-Control: no-cache`), ensuring instantaneous discovery of new package releases.
|
|
83
86
|
* 🔄 **Automated Catalog Discovery**: Syncs model lists, aliases, context windows, and capability flags (`vision`, `tools`, `reasoning`, `embeddings`) across 25+ providers.
|
package/README.ru.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ Ключевые возможности
|
|
80
80
|
|
|
81
|
+
* 💰 **Встроенная база эталонных цен токенов**: Автоматическое обогащение моделей тарифами для OpenAI, Anthropic, Groq, Mistral, xAI и DeepSeek, обеспечивающее работу лимитов цен и псевдонима `@cheap` даже при отсутствии метаданных в API провайдера.
|
|
82
|
+
* 🎯 **Динамические «умные» псевдонимы и авто-фоллбэк**: Поддержка виртуальных алиасов (`@fast`, `@cheap`, `@best-code`) и цепочек резервирования с автопереключением при размыкании Circuit Breaker.
|
|
83
|
+
* 🔔 **Внешние Diff Webhooks и диспетчеризация событий**: Уведомления в реальном времени с проверкой подписи HMAC-SHA256 и событиями Cordis `model-sync/diff` при добавлении моделей, их устаревании или изменении цен.
|
|
81
84
|
* ⚡ **Высокопроизводительная фильтрация моделей**: Быстрый путь (`fast-path`) в `filterModels` мгновенно возвращает каталог при отсутствии активных правил политики, исключая лишнюю компиляцию regex и прогоны массивов.
|
|
82
85
|
* 🔄 **Обновление плагина в один клик**: Встроенный механизм проверки обновлений в Настройках со сбросом кэша npm при ручном запросе (`?fresh=1` / `Cache-Control: no-cache`), гарантирующий получение актуальной версии.
|
|
83
86
|
* 🔄 **Автоматическое обнаружение каталога**: синхронизация списков моделей, алиасов, контекстных окон и флагов возможностей (`vision`, `tools`, `reasoning`, `embeddings`) для 25+ провайдеров.
|
package/README.zh.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ 核心特性
|
|
80
80
|
|
|
81
|
+
* 💰 **内置基准价格数据库**:自动为 OpenAI、Anthropic、Groq、Mistral、xAI 和 DeepSeek 模型补充 Token 价格数据,即使服务商接口未返回价格,也可无缝支持成本上限过滤与 `@cheap` 别名解析。
|
|
82
|
+
* 🎯 **动态智能虚拟别名与故障转移**:支持 `@fast`、`@cheap`、`@best-code` 等虚拟别名及故障转移链,在熔断器打开或健康检查失败时自动切换至备用模型。
|
|
83
|
+
* 🔔 **外部差异通知 Webhook 与事件分发**:支持 HMAC-SHA256 签名验证的实时 Webhook 通知与 Cordis `model-sync/diff` 事件,可在新模型上线、模型废弃或价格变更时即时推送。
|
|
81
84
|
* ⚡ **高性能模型策略过滤**:在未配置过滤策略时,`filterModels` 采用快速路径直接返回模型目录,避免不必要的正则表达式编译和数组遍历。
|
|
82
85
|
* 🔄 **一键检查与更新**:内置设置面板更新检查,在手动刷新时通过 `?fresh=1` 或 `Cache-Control: no-cache` 绕过 npm 缓存,即时获取最新版本。
|
|
83
86
|
* 🔄 **自动化目录同步**:自动对齐 25+ 服务商的模型清单、别名、上下文窗口及能力标签(`vision`、`tools`、`reasoning`、`embeddings`)。
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
export const VIRTUAL_STRATEGIES = Object.freeze(['fastest', 'cheapest', 'best-code'])
|
|
2
|
+
|
|
3
|
+
export function isVirtualAlias(name) {
|
|
4
|
+
if (typeof name !== 'string') return false
|
|
5
|
+
const clean = name.trim().toLowerCase()
|
|
6
|
+
return clean === '@fast' || clean === '@cheap' || clean === '@best-code' || clean.startsWith('@')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateAliasName(name) {
|
|
10
|
+
if (typeof name !== 'string') return false
|
|
11
|
+
const clean = name.trim()
|
|
12
|
+
return /^@?[a-zA-Z0-9_\-.]{1,64}$/.test(clean)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function modelScoreForCode(provider, model) {
|
|
16
|
+
let score = 0
|
|
17
|
+
const id = String(model?.id ?? '').toLowerCase()
|
|
18
|
+
const name = String(model?.name ?? '').toLowerCase()
|
|
19
|
+
|
|
20
|
+
if (model?.capabilities?.code) score += 100
|
|
21
|
+
if (model?.capabilities?.reasoning) score += 50
|
|
22
|
+
|
|
23
|
+
if (id.includes('claude-3-7-sonnet') || id.includes('claude-3-5-sonnet') || id.includes('claude-3.5-sonnet')) score += 200
|
|
24
|
+
else if (id.includes('o3') || id.includes('o1') || id.includes('gpt-4o')) score += 180
|
|
25
|
+
else if (id.includes('deepseek-r1') || id.includes('deepseek-v3') || id.includes('deepseek-coder')) score += 170
|
|
26
|
+
else if (id.includes('qwen-2.5-coder') || id.includes('qwen2.5-coder') || id.includes('qwen-coder')) score += 160
|
|
27
|
+
else if (id.includes('codestral') || id.includes('devin') || id.includes('code')) score += 120
|
|
28
|
+
|
|
29
|
+
if (model?.contextWindow && model.contextWindow >= 128000) score += 20
|
|
30
|
+
return score
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function estimateLatency(provider, model, probes = {}) {
|
|
34
|
+
const key = String(provider) + ':' + String(model.id)
|
|
35
|
+
if (probes[key]?.latencyMs && Number.isFinite(probes[key].latencyMs)) {
|
|
36
|
+
return probes[key].latencyMs
|
|
37
|
+
}
|
|
38
|
+
let base = 800
|
|
39
|
+
const prov = String(provider).toLowerCase()
|
|
40
|
+
if (prov === 'groq' || prov === 'cerebras') base = 120
|
|
41
|
+
else if (prov === 'fireworks') base = 250
|
|
42
|
+
else if (prov === 'together') base = 350
|
|
43
|
+
else if (prov === 'deepseek') base = 400
|
|
44
|
+
else if (prov === 'openai') base = 500
|
|
45
|
+
|
|
46
|
+
const id = String(model.id).toLowerCase()
|
|
47
|
+
if (id.includes('flash') || id.includes('instant') || id.includes('turbo') || id.includes('haiku')) base *= 0.6
|
|
48
|
+
if (id.includes('mini') || id.includes('8b') || id.includes('7b')) base *= 0.7
|
|
49
|
+
if (id.includes('reasoning') || id.includes('r1') || id.includes('o1')) base *= 2.5
|
|
50
|
+
return Math.round(base)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveAlias(aliasName, {
|
|
54
|
+
aliases = {},
|
|
55
|
+
modelCatalogs = {},
|
|
56
|
+
healthResults = [],
|
|
57
|
+
isCircuitOpen = () => false,
|
|
58
|
+
modelProbes = {},
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (typeof aliasName !== 'string' || !aliasName.trim()) {
|
|
61
|
+
throw Object.assign(new Error('alias name is required'), { code: 'INVALID_ALIAS' })
|
|
62
|
+
}
|
|
63
|
+
const clean = aliasName.trim()
|
|
64
|
+
const custom = aliases[clean]
|
|
65
|
+
|
|
66
|
+
// 1. Fallback chain alias
|
|
67
|
+
if (custom?.targets && Array.isArray(custom.targets) && custom.targets.length > 0) {
|
|
68
|
+
const skipped = []
|
|
69
|
+
for (let i = 0; i < custom.targets.length; i++) {
|
|
70
|
+
const target = custom.targets[i]
|
|
71
|
+
const circuitBlocked = isCircuitOpen(target.provider)
|
|
72
|
+
const healthFail = healthResults.find((h) => h.provider === target.provider && h.status === 'error')
|
|
73
|
+
if (circuitBlocked || healthFail) {
|
|
74
|
+
skipped.push({ ...target, reason: circuitBlocked ? 'circuit_open' : 'health_failed' })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
alias: clean,
|
|
79
|
+
provider: target.provider,
|
|
80
|
+
model: target.model,
|
|
81
|
+
fallbackUsed: i > 0,
|
|
82
|
+
index: i,
|
|
83
|
+
skipped,
|
|
84
|
+
reason: i > 0 ? 'fallback_selected' : 'primary_healthy',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// All targets had issues, return first as best-effort with warning
|
|
88
|
+
return {
|
|
89
|
+
alias: clean,
|
|
90
|
+
provider: custom.targets[0].provider,
|
|
91
|
+
model: custom.targets[0].model,
|
|
92
|
+
fallbackUsed: true,
|
|
93
|
+
warning: 'all_targets_unhealthy',
|
|
94
|
+
skipped,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Static explicit alias
|
|
99
|
+
if (custom?.provider && custom?.model && !custom.strategy) {
|
|
100
|
+
const circuitBlocked = isCircuitOpen(custom.provider)
|
|
101
|
+
return {
|
|
102
|
+
alias: clean,
|
|
103
|
+
provider: custom.provider,
|
|
104
|
+
model: custom.model,
|
|
105
|
+
fallbackUsed: false,
|
|
106
|
+
...(circuitBlocked ? { warning: 'circuit_open', circuitOpen: true } : {}),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Strategy resolution (@fast, @cheap, @best-code or custom.strategy)
|
|
111
|
+
let strategy = custom?.strategy
|
|
112
|
+
const lower = clean.toLowerCase()
|
|
113
|
+
if (!strategy) {
|
|
114
|
+
if (lower === '@fast') strategy = 'fastest'
|
|
115
|
+
else if (lower === '@cheap') strategy = 'cheapest'
|
|
116
|
+
else if (lower === '@best-code') strategy = 'best-code'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (strategy) {
|
|
120
|
+
const available = []
|
|
121
|
+
for (const [provider, models] of Object.entries(modelCatalogs)) {
|
|
122
|
+
if (isCircuitOpen(provider)) continue
|
|
123
|
+
if (!Array.isArray(models)) continue
|
|
124
|
+
for (const m of models) {
|
|
125
|
+
if (!m?.id) continue
|
|
126
|
+
available.push({ provider, model: m })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (available.length === 0) {
|
|
131
|
+
return { alias: clean, strategy, resolved: null, error: 'NO_HEALTHY_MODELS' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (strategy === 'fastest') {
|
|
135
|
+
const scored = available.map((item) => ({
|
|
136
|
+
...item,
|
|
137
|
+
latencyMs: estimateLatency(item.provider, item.model, modelProbes),
|
|
138
|
+
}))
|
|
139
|
+
scored.sort((a, b) => a.latencyMs - b.latencyMs)
|
|
140
|
+
const best = scored[0]
|
|
141
|
+
return {
|
|
142
|
+
alias: clean,
|
|
143
|
+
strategy: 'fastest',
|
|
144
|
+
provider: best.provider,
|
|
145
|
+
model: best.model.id,
|
|
146
|
+
name: best.model.name || best.model.id,
|
|
147
|
+
latencyMs: best.latencyMs,
|
|
148
|
+
fallbackUsed: false,
|
|
149
|
+
reason: 'lowest_latency',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (strategy === 'cheapest') {
|
|
154
|
+
const priced = available.filter((item) => {
|
|
155
|
+
const p = item.model?.pricing
|
|
156
|
+
return p && (Number.isFinite(p.inputPerToken) || Number.isFinite(p.outputPerToken))
|
|
157
|
+
}).map((item) => {
|
|
158
|
+
const p = item.model.pricing
|
|
159
|
+
const cost = (p.inputPerToken || 0) + (p.outputPerToken || 0)
|
|
160
|
+
return { ...item, cost }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (priced.length > 0) {
|
|
164
|
+
priced.sort((a, b) => a.cost - b.cost)
|
|
165
|
+
const best = priced[0]
|
|
166
|
+
return {
|
|
167
|
+
alias: clean,
|
|
168
|
+
strategy: 'cheapest',
|
|
169
|
+
provider: best.provider,
|
|
170
|
+
model: best.model.id,
|
|
171
|
+
name: best.model.name || best.model.id,
|
|
172
|
+
pricing: best.model.pricing,
|
|
173
|
+
costPerToken: best.cost,
|
|
174
|
+
fallbackUsed: false,
|
|
175
|
+
reason: 'lowest_price',
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// If no pricing metadata available, pick a mini/flash model as fallback
|
|
179
|
+
const fallback = available.find((a) => /(mini|flash|small|lite)/i.test(a.model.id)) || available[0]
|
|
180
|
+
return {
|
|
181
|
+
alias: clean,
|
|
182
|
+
strategy: 'cheapest',
|
|
183
|
+
provider: fallback.provider,
|
|
184
|
+
model: fallback.model.id,
|
|
185
|
+
name: fallback.model.name || fallback.model.id,
|
|
186
|
+
fallbackUsed: true,
|
|
187
|
+
reason: 'heuristic_cheapest_fallback',
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (strategy === 'best-code') {
|
|
192
|
+
const scored = available.map((item) => ({
|
|
193
|
+
...item,
|
|
194
|
+
score: modelScoreForCode(item.provider, item.model),
|
|
195
|
+
}))
|
|
196
|
+
scored.sort((a, b) => b.score - a.score)
|
|
197
|
+
const best = scored[0]
|
|
198
|
+
return {
|
|
199
|
+
alias: clean,
|
|
200
|
+
strategy: 'best-code',
|
|
201
|
+
provider: best.provider,
|
|
202
|
+
model: best.model.id,
|
|
203
|
+
name: best.model.name || best.model.id,
|
|
204
|
+
capabilities: best.model.capabilities,
|
|
205
|
+
score: best.score,
|
|
206
|
+
fallbackUsed: false,
|
|
207
|
+
reason: 'top_coding_capability',
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw Object.assign(new Error("Unknown alias: " + clean), { code: 'UNKNOWN_ALIAS' })
|
|
213
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -202,6 +202,18 @@ window.__ModuleLoader__.load({
|
|
|
202
202
|
scheduleEnabled: 'Enable background schedule',
|
|
203
203
|
intervalMinutes: 'Interval (minutes)',
|
|
204
204
|
autoApplySchedule: 'Auto-apply changes',
|
|
205
|
+
diffWebhooks: 'External Diff Webhooks',
|
|
206
|
+
diffWebhookUrl: 'Webhook URL',
|
|
207
|
+
diffWebhookSecret: 'Webhook Secret (optional)',
|
|
208
|
+
notifyOnModelAdded: 'Notify on new models',
|
|
209
|
+
notifyOnModelDeprecated: 'Notify on deprecated / removed models',
|
|
210
|
+
notifyOnPriceChanged: 'Notify on price changes',
|
|
211
|
+
saveWebhook: 'Save Webhook Settings',
|
|
212
|
+
testWebhook: 'Test Webhook',
|
|
213
|
+
webhookTesting: 'Testing...',
|
|
214
|
+
webhookSuccess: 'Webhook test succeeded',
|
|
215
|
+
webhookFailed: 'Webhook test failed: %s',
|
|
216
|
+
webhookSaved: 'Webhook settings saved',
|
|
205
217
|
saveScheduler: 'Save scheduler',
|
|
206
218
|
schedulerSaved: 'Saved',
|
|
207
219
|
scopeUnavailable: 'Settings service unavailable.',
|
|
@@ -222,6 +234,13 @@ window.__ModuleLoader__.load({
|
|
|
222
234
|
importError: 'Import failed: %s',
|
|
223
235
|
importPlaceholder: 'Paste JSON configuration here…',
|
|
224
236
|
modelAliases: 'Model Aliases',
|
|
237
|
+
virtualAliases: 'Virtual Smart Aliases',
|
|
238
|
+
resolveAlias: 'Test Resolve',
|
|
239
|
+
resolvingAlias: 'Resolving...',
|
|
240
|
+
virtualAliasDesc: 'Dynamic aliases route to optimal models based on latency, pricing, and health probes.',
|
|
241
|
+
aliasFastDesc: 'Fastest response time among healthy models',
|
|
242
|
+
aliasCheapDesc: 'Lowest token pricing available',
|
|
243
|
+
aliasBestCodeDesc: 'Top coding & reasoning capabilities',
|
|
225
244
|
aliasPlaceholder: 'Alias (e.g. gpt-4o-latest)',
|
|
226
245
|
targetModel: 'Target model',
|
|
227
246
|
addAlias: 'Save alias',
|
|
@@ -333,6 +352,18 @@ window.__ModuleLoader__.load({
|
|
|
333
352
|
scheduleEnabled: '开启后台定时同步',
|
|
334
353
|
intervalMinutes: '间隔时间(分钟)',
|
|
335
354
|
autoApplySchedule: '自动应用变更',
|
|
355
|
+
diffWebhooks: '外部差异通知 Webhook',
|
|
356
|
+
diffWebhookUrl: 'Webhook 地址',
|
|
357
|
+
diffWebhookSecret: 'Webhook 密钥(可选)',
|
|
358
|
+
notifyOnModelAdded: '新模型添加时通知',
|
|
359
|
+
notifyOnModelDeprecated: '模型废弃/移除时通知',
|
|
360
|
+
notifyOnPriceChanged: '模型价格变更时通知',
|
|
361
|
+
saveWebhook: '保存 Webhook 设置',
|
|
362
|
+
testWebhook: '测试 Webhook',
|
|
363
|
+
webhookTesting: '正在测试...',
|
|
364
|
+
webhookSuccess: 'Webhook 测试成功',
|
|
365
|
+
webhookFailed: 'Webhook 测试失败:%s',
|
|
366
|
+
webhookSaved: 'Webhook 设置已保存',
|
|
336
367
|
saveScheduler: '保存计划',
|
|
337
368
|
schedulerSaved: '已保存',
|
|
338
369
|
scopeUnavailable: '设置服务不可用。',
|
|
@@ -353,6 +384,13 @@ window.__ModuleLoader__.load({
|
|
|
353
384
|
importError: '导入失败:%s',
|
|
354
385
|
importPlaceholder: '在此粘贴 JSON 配置内容…',
|
|
355
386
|
modelAliases: '模型别名映射',
|
|
387
|
+
virtualAliases: '智能虚拟别名',
|
|
388
|
+
resolveAlias: '解析测试',
|
|
389
|
+
resolvingAlias: '正在解析...',
|
|
390
|
+
virtualAliasDesc: '动态别名根据实时延迟、定价与可用性探测动态路由至最优模型。',
|
|
391
|
+
aliasFastDesc: '所有健康模型中响应延迟最低',
|
|
392
|
+
aliasCheapDesc: 'Token 单价最低的可用模型',
|
|
393
|
+
aliasBestCodeDesc: '代码与推理能力评分最优',
|
|
356
394
|
aliasPlaceholder: '别名(例如 gpt-4o-latest)',
|
|
357
395
|
targetModel: '目标模型',
|
|
358
396
|
addAlias: '保存别名',
|
|
@@ -519,6 +557,8 @@ window.__ModuleLoader__.load({
|
|
|
519
557
|
const [aliasDraft, setAliasDraft] = React.useState({ alias: '', provider: '', model: '' })
|
|
520
558
|
const [aliasMsg, setAliasMsg] = React.useState('')
|
|
521
559
|
const [aliasErr, setAliasErr] = React.useState('')
|
|
560
|
+
const [resolvedAliases, setResolvedAliases] = React.useState({})
|
|
561
|
+
const [resolvingKey, setResolvingKey] = React.useState('')
|
|
522
562
|
|
|
523
563
|
const [importText, setImportText] = React.useState('')
|
|
524
564
|
const [importMsg, setImportMsg] = React.useState('')
|
|
@@ -534,6 +574,10 @@ window.__ModuleLoader__.load({
|
|
|
534
574
|
const snapStatus = snapshot?.status || 'loading'
|
|
535
575
|
const stored = snapshot?.value || {}
|
|
536
576
|
const [schedDraft, setSchedDraft] = React.useState(null)
|
|
577
|
+
const [webhookDraft, setWebhookDraft] = React.useState(null)
|
|
578
|
+
const [webhookMsg, setWebhookMsg] = React.useState('')
|
|
579
|
+
const [webhookErr, setWebhookErr] = React.useState('')
|
|
580
|
+
const [webhookTesting, setWebhookTesting] = React.useState(false)
|
|
537
581
|
const [schedMsg, setSchedMsg] = React.useState('')
|
|
538
582
|
const [schedErr, setSchedErr] = React.useState('')
|
|
539
583
|
|
|
@@ -543,6 +587,48 @@ window.__ModuleLoader__.load({
|
|
|
543
587
|
}
|
|
544
588
|
}, [snapStatus, stored, schedDraft])
|
|
545
589
|
|
|
590
|
+
const saveWebhook = async () => {
|
|
591
|
+
if (!scope || !webhookDraft) return
|
|
592
|
+
setBusy(true); setWebhookErr(''); setWebhookMsg('')
|
|
593
|
+
try {
|
|
594
|
+
await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
|
|
595
|
+
await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
|
|
596
|
+
await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
|
|
597
|
+
await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
|
|
598
|
+
await scope.set('notifyOnPriceChanged', !!webhookDraft.notifyOnPriceChanged)
|
|
599
|
+
setWebhookMsg(t.webhookSaved)
|
|
600
|
+
} catch (e) {
|
|
601
|
+
setWebhookErr(String(e?.message ?? e))
|
|
602
|
+
} finally {
|
|
603
|
+
setBusy(false)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const testWebhook = async () => {
|
|
608
|
+
if (!webhookDraft?.diffWebhookUrl) return
|
|
609
|
+
setWebhookTesting(true); setWebhookErr(''); setWebhookMsg('')
|
|
610
|
+
try {
|
|
611
|
+
const res = await fetch(`${API}/webhook/test`, {
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: { 'content-type': 'application/json' },
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
url: webhookDraft.diffWebhookUrl,
|
|
616
|
+
secret: webhookDraft.diffWebhookSecret || '',
|
|
617
|
+
}),
|
|
618
|
+
})
|
|
619
|
+
const body = await res.json()
|
|
620
|
+
if (res.ok && body.ok) {
|
|
621
|
+
setWebhookMsg(t.webhookSuccess)
|
|
622
|
+
} else {
|
|
623
|
+
setWebhookErr((t.webhookFailed || 'Webhook test failed: %s').replace('%s', body.error || `HTTP ${body.status || res.status}`))
|
|
624
|
+
}
|
|
625
|
+
} catch (e) {
|
|
626
|
+
setWebhookErr((t.webhookFailed || 'Webhook test failed: %s').replace('%s', String(e?.message ?? e)))
|
|
627
|
+
} finally {
|
|
628
|
+
setWebhookTesting(false)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
546
632
|
const saveScheduler = async () => {
|
|
547
633
|
if (!scope || !schedDraft) return
|
|
548
634
|
setBusy(true); setSchedErr(''); setSchedMsg('')
|
|
@@ -918,6 +1004,55 @@ window.__ModuleLoader__.load({
|
|
|
918
1004
|
const visibleChoices = filteredChoices.slice(0, pageSize)
|
|
919
1005
|
const button = (label, onClick, variant) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' + (variant ? ' dms-btn-' + variant : '') }, label)
|
|
920
1006
|
|
|
1007
|
+
|
|
1008
|
+
const renderWebhooks = () => {
|
|
1009
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' } },
|
|
1010
|
+
snapStatus === 'loading' ? React.createElement('small', null, t.loading) : null,
|
|
1011
|
+
snapStatus === 'unavailable' ? React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.scopeUnavailable) : null,
|
|
1012
|
+
webhookDraft ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 } },
|
|
1013
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
|
|
1014
|
+
React.createElement('span', { style: { minWidth: 160 } }, t.diffWebhookUrl + ':'),
|
|
1015
|
+
React.createElement('input', {
|
|
1016
|
+
type: 'url',
|
|
1017
|
+
placeholder: 'https://example.com/webhook',
|
|
1018
|
+
value: webhookDraft.diffWebhookUrl,
|
|
1019
|
+
onChange: (e) => setWebhookDraft((d) => ({ ...d, diffWebhookUrl: e.target.value })),
|
|
1020
|
+
disabled: busy || webhookTesting,
|
|
1021
|
+
style: { flex: '1 1 240px', padding: '4px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'inherit' }
|
|
1022
|
+
})
|
|
1023
|
+
),
|
|
1024
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
|
|
1025
|
+
React.createElement('span', { style: { minWidth: 160 } }, t.diffWebhookSecret + ':'),
|
|
1026
|
+
React.createElement('input', {
|
|
1027
|
+
type: 'password',
|
|
1028
|
+
placeholder: '••••••••',
|
|
1029
|
+
value: webhookDraft.diffWebhookSecret,
|
|
1030
|
+
onChange: (e) => setWebhookDraft((d) => ({ ...d, diffWebhookSecret: e.target.value })),
|
|
1031
|
+
disabled: busy || webhookTesting,
|
|
1032
|
+
style: { flex: '1 1 240px', padding: '4px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'inherit' }
|
|
1033
|
+
})
|
|
1034
|
+
),
|
|
1035
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
|
|
1036
|
+
React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnModelAdded, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnModelAdded: e.target.checked })), disabled: busy }),
|
|
1037
|
+
t.notifyOnModelAdded
|
|
1038
|
+
),
|
|
1039
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
|
|
1040
|
+
React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnModelDeprecated, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnModelDeprecated: e.target.checked })), disabled: busy }),
|
|
1041
|
+
t.notifyOnModelDeprecated
|
|
1042
|
+
),
|
|
1043
|
+
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
|
|
1044
|
+
React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnPriceChanged, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnPriceChanged: e.target.checked })), disabled: busy }),
|
|
1045
|
+
t.notifyOnPriceChanged
|
|
1046
|
+
),
|
|
1047
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } },
|
|
1048
|
+
button(t.saveWebhook, saveWebhook, 'primary'),
|
|
1049
|
+
button(webhookTesting ? t.webhookTesting : t.testWebhook, testWebhook, 'secondary'),
|
|
1050
|
+
webhookMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, webhookMsg) : null,
|
|
1051
|
+
webhookErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, webhookErr) : null
|
|
1052
|
+
)
|
|
1053
|
+
) : null
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
921
1056
|
const renderNotifications = () => {
|
|
922
1057
|
const entries = status?.notifications ?? []
|
|
923
1058
|
if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
|
|
@@ -945,12 +1080,37 @@ window.__ModuleLoader__.load({
|
|
|
945
1080
|
const renderAliases = () => {
|
|
946
1081
|
const aliasMap = status?.aliases ?? {}
|
|
947
1082
|
const entries = Object.entries(aliasMap)
|
|
948
|
-
|
|
1083
|
+
const smartPresets = [
|
|
1084
|
+
{ name: '@fast', label: t.aliasFastDesc },
|
|
1085
|
+
{ name: '@cheap', label: t.aliasCheapDesc },
|
|
1086
|
+
{ name: '@best-code', label: t.aliasBestCodeDesc },
|
|
1087
|
+
]
|
|
1088
|
+
|
|
1089
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' } },
|
|
1090
|
+
React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 2 } }, t.virtualAliasDesc),
|
|
1091
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
1092
|
+
smartPresets.map((p) => {
|
|
1093
|
+
const res = resolvedAliases[p.name]
|
|
1094
|
+
const isResolving = resolvingKey === p.name
|
|
1095
|
+
return React.createElement('div', { key: p.name, style: { display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 10px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 6, background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
1096
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
1097
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
1098
|
+
React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13, color: 'var(--dsw-alias-state-brand-primary)' } }, p.name),
|
|
1099
|
+
React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, p.label)
|
|
1100
|
+
),
|
|
1101
|
+
button(isResolving ? t.resolvingAlias : t.resolveAlias, () => resolveSmartAlias(p.name), 'secondary')
|
|
1102
|
+
),
|
|
1103
|
+
res ? (res.error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, res.error)
|
|
1104
|
+
: React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, `→ ${res.provider} / ${res.model} (${res.reason || res.strategy}${res.latencyMs ? ` · ${res.latencyMs}ms` : ''})`)) : null
|
|
1105
|
+
)
|
|
1106
|
+
})
|
|
1107
|
+
),
|
|
949
1108
|
entries.length === 0 ? React.createElement('small', { style: { display: 'block', marginTop: 4 } }, t.noAliases) : entries.map(([aliasName, data]) => {
|
|
1109
|
+
const targetText = data.targets ? `${data.targets.length} fallback targets` : data.strategy ? `strategy: ${data.strategy}` : `${data.provider} / ${data.model}`
|
|
950
1110
|
return React.createElement('div', { key: aliasName, className: 'dms-row', style: { justifyContent: 'space-between', padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 6, background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
951
1111
|
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
952
1112
|
React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13 } }, aliasName),
|
|
953
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, `→ ${
|
|
1113
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, `→ ${targetText}`)
|
|
954
1114
|
),
|
|
955
1115
|
button(t.removeAlias, () => deleteAlias(aliasName), 'danger')
|
|
956
1116
|
)
|
|
@@ -1212,6 +1372,10 @@ window.__ModuleLoader__.load({
|
|
|
1212
1372
|
React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.notifications)),
|
|
1213
1373
|
renderNotifications()
|
|
1214
1374
|
),
|
|
1375
|
+
React.createElement('div', { className: 'dms-section-card dms-section' },
|
|
1376
|
+
React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.diffWebhooks)),
|
|
1377
|
+
renderWebhooks()
|
|
1378
|
+
),
|
|
1215
1379
|
React.createElement('div', { className: 'dms-section-card dms-section' },
|
|
1216
1380
|
React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.credentials)),
|
|
1217
1381
|
credentialStatus === null ? React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.loading) : (credentialStatus.results ?? []).length === 0 ? React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noProviders) : (credentialStatus.results ?? []).map((item) => React.createElement('div', { key: item.provider, style: { marginTop: 6 } }, React.createElement('span', null, `${item.provider}: ${item.rotation?.configured ? `${item.rotation.keyCount} refs` : 'single ref'}${item.lastRequest ? ` · ${item.lastRequest.status}` : ''}`), (item.refs ?? []).map((ref) => React.createElement('small', { key: ref.ref || ref.label, style: { display: 'block', marginLeft: 12 } }, `${ref.order}. ${ref.label} · ${ref.configured === true ? t.credentialReady : t.missingCredential}${ref.source && ref.source !== 'unknown' ? ` · ${ref.source}` : ''}${ref.lastResolution?.status ? ` · ${ref.lastResolution.status}` : ''}`))))
|
|
@@ -1271,18 +1435,22 @@ window.__ModuleLoader__.load({
|
|
|
1271
1435
|
|
|
1272
1436
|
function apply(ctx) {
|
|
1273
1437
|
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-model-sync: dictionaries')
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
|
|
1282
|
-
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 }))))
|
|
1283
|
-
} catch (err) {
|
|
1284
|
-
try { console.debug('[dsh-model-sync] slots inject best-effort:', err) } catch { /* console unavailable */ }
|
|
1438
|
+
const trySlot = (name, register) => {
|
|
1439
|
+
try {
|
|
1440
|
+
if (typeof ctx.slots?.inject === 'function') ctx.slots.inject(name, register)
|
|
1441
|
+
else if (typeof ctx.slots?.register === 'function') register()
|
|
1442
|
+
} catch (err) {
|
|
1443
|
+
try { console.warn('[dsh-model-sync] slot register failed for ' + name + ':', err?.message ?? err) } catch { /* console unavailable */ }
|
|
1444
|
+
}
|
|
1285
1445
|
}
|
|
1446
|
+
// Plugin-list seat (plugins.item) first: it is the seat the current core
|
|
1447
|
+
// (0.1.6-alpha.2) renders as the plugin's own page with its configuration. The
|
|
1448
|
+
// label is a static string on purpose — it is resolved while the page renders,
|
|
1449
|
+
// and a locale lookup there would take the whole client batch down with it.
|
|
1450
|
+
trySlot('plugins.item', () => ctx.slots.register({ name: 'plugins.item', id: ROW_ID, order: 60, label: () => 'Model synchronization', locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
|
|
1451
|
+
// Row seat and the legacy seat stay as fallbacks.
|
|
1452
|
+
trySlot('plugins.row.config', () => ctx.slots.register({ name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
|
|
1453
|
+
trySlot('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 }))))
|
|
1286
1454
|
}
|
|
1287
1455
|
|
|
1288
1456
|
module.exports = { apply, inject: ['slots', 'locale', 'configForms'], filterModelsByCapabilities }
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
export function extractDiffEvents(results, options = {}) {
|
|
4
|
+
const notifyOnModelAdded = options.notifyOnModelAdded !== false
|
|
5
|
+
const notifyOnModelDeprecated = options.notifyOnModelDeprecated !== false
|
|
6
|
+
const notifyOnPriceChanged = options.notifyOnPriceChanged !== false
|
|
7
|
+
|
|
8
|
+
const added = []
|
|
9
|
+
const deprecated = []
|
|
10
|
+
const priceChanged = []
|
|
11
|
+
|
|
12
|
+
const rows = Array.isArray(results) ? results : []
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
const provider = String(row?.provider ?? "")
|
|
15
|
+
const diff = row?.diff ?? {}
|
|
16
|
+
|
|
17
|
+
if (notifyOnModelAdded && Array.isArray(diff.added)) {
|
|
18
|
+
for (const model of diff.added) {
|
|
19
|
+
if (!model?.id) continue
|
|
20
|
+
added.push({
|
|
21
|
+
provider,
|
|
22
|
+
id: model.id,
|
|
23
|
+
name: model.name || model.id,
|
|
24
|
+
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
|
25
|
+
...(model.pricing ? { pricing: model.pricing } : {}),
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (notifyOnModelDeprecated) {
|
|
31
|
+
if (Array.isArray(diff.removed)) {
|
|
32
|
+
for (const model of diff.removed) {
|
|
33
|
+
if (!model?.id) continue
|
|
34
|
+
deprecated.push({
|
|
35
|
+
provider,
|
|
36
|
+
id: model.id,
|
|
37
|
+
name: model.name || model.id,
|
|
38
|
+
reason: "removed",
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(diff.changed)) {
|
|
43
|
+
for (const pair of diff.changed) {
|
|
44
|
+
const beforeDep = pair?.before?.lifecycle?.deprecated === true
|
|
45
|
+
const afterDep = pair?.after?.lifecycle?.deprecated === true
|
|
46
|
+
const beforeDate = pair?.before?.lifecycle?.deprecationDate
|
|
47
|
+
const afterDate = pair?.after?.lifecycle?.deprecationDate
|
|
48
|
+
if ((afterDep && !beforeDep) || (afterDate && afterDate !== beforeDate)) {
|
|
49
|
+
deprecated.push({
|
|
50
|
+
provider,
|
|
51
|
+
id: pair.after.id,
|
|
52
|
+
name: pair.after.name || pair.after.id,
|
|
53
|
+
reason: "deprecated",
|
|
54
|
+
...(afterDate ? { deprecationDate: afterDate } : {}),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(row?.stale)) {
|
|
60
|
+
for (const model of row.stale) {
|
|
61
|
+
if (!model?.id) continue
|
|
62
|
+
deprecated.push({
|
|
63
|
+
provider,
|
|
64
|
+
id: model.id,
|
|
65
|
+
name: model.name || model.id,
|
|
66
|
+
reason: "stale",
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (notifyOnPriceChanged && Array.isArray(diff.changed)) {
|
|
73
|
+
for (const pair of diff.changed) {
|
|
74
|
+
const bp = pair?.before?.pricing
|
|
75
|
+
const ap = pair?.after?.pricing
|
|
76
|
+
if (bp || ap) {
|
|
77
|
+
const inputDiff = bp?.inputPerToken !== ap?.outputPerToken && bp?.inputPerToken !== ap?.inputPerToken
|
|
78
|
+
const outputDiff = bp?.outputPerToken !== ap?.outputPerToken
|
|
79
|
+
if (inputDiff || outputDiff) {
|
|
80
|
+
priceChanged.push({
|
|
81
|
+
provider,
|
|
82
|
+
id: pair.after.id,
|
|
83
|
+
name: pair.after.name || pair.after.id,
|
|
84
|
+
oldPricing: bp || null,
|
|
85
|
+
newPricing: ap || null,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
hasEvents: added.length > 0 || deprecated.length > 0 || priceChanged.length > 0,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
counts: {
|
|
97
|
+
added: added.length,
|
|
98
|
+
deprecated: deprecated.length,
|
|
99
|
+
priceChanged: priceChanged.length,
|
|
100
|
+
},
|
|
101
|
+
added,
|
|
102
|
+
deprecated,
|
|
103
|
+
priceChanged,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function sendWebhookNotification(url, secret, payload, { fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
|
|
108
|
+
if (typeof url !== "string" || !url.trim()) {
|
|
109
|
+
return { ok: false, error: "URL is required" }
|
|
110
|
+
}
|
|
111
|
+
let parsedUrl
|
|
112
|
+
try {
|
|
113
|
+
parsedUrl = new URL(url.trim())
|
|
114
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
115
|
+
return { ok: false, error: "URL must use http or https protocol" }
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { ok: false, error: "Invalid URL: " + (err?.message || String(err)) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const body = JSON.stringify(payload)
|
|
122
|
+
const headers = {
|
|
123
|
+
"content-type": "application/json",
|
|
124
|
+
"user-agent": "dsh-model-sync/diff-notifier",
|
|
125
|
+
}
|
|
126
|
+
if (secret && typeof secret === "string" && secret.trim()) {
|
|
127
|
+
const signature = createHmac("sha256", secret.trim()).update(body).digest("hex")
|
|
128
|
+
headers["x-hub-signature-256"] = "sha256=" + signature
|
|
129
|
+
headers["x-signature"] = signature
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : null
|
|
133
|
+
const timer = controller ? setTimeout(() => controller.abort(new Error("Webhook request timed out")), timeoutMs) : null
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetchImpl(parsedUrl.toString(), {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body,
|
|
140
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
141
|
+
})
|
|
142
|
+
if (timer) clearTimeout(timer)
|
|
143
|
+
return { ok: res.ok, status: res.status }
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (timer) clearTimeout(timer)
|
|
146
|
+
return { ok: false, error: err?.message || String(err) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function dispatchDiffNotifications(ctx, config, results, { fetchImpl = globalThis.fetch } = {}) {
|
|
151
|
+
const events = extractDiffEvents(results, config)
|
|
152
|
+
if (!events.hasEvents) {
|
|
153
|
+
return { ok: true, events, dispatched: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (typeof ctx?.emit === "function") {
|
|
158
|
+
ctx.emit("model-sync/diff", events)
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (typeof ctx?.logger?.debug === "function") {
|
|
162
|
+
ctx.logger.debug("[dsh-model-sync] emit model-sync/diff error:", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
if (typeof ctx?.pluginNotify?.send === "function") {
|
|
168
|
+
const summary = `Model Sync: +${events.counts.added} added, -${events.counts.deprecated} deprecated, ~${events.counts.priceChanged} price changed`
|
|
169
|
+
await ctx.pluginNotify.send({
|
|
170
|
+
source: "dsh-model-sync",
|
|
171
|
+
title: "Model Sync Catalog Changes",
|
|
172
|
+
message: summary,
|
|
173
|
+
data: events,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (typeof ctx?.logger?.debug === "function") {
|
|
178
|
+
ctx.logger.debug("[dsh-model-sync] pluginNotify error:", err)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let webhookResult = null
|
|
183
|
+
if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
|
|
184
|
+
webhookResult = await sendWebhookNotification(
|
|
185
|
+
config.diffWebhookUrl,
|
|
186
|
+
config.diffWebhookSecret,
|
|
187
|
+
{
|
|
188
|
+
event: "model-sync.diff",
|
|
189
|
+
...events,
|
|
190
|
+
},
|
|
191
|
+
{ fetchImpl }
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { ok: true, events, dispatched: true, webhook: webhookResult }
|
|
196
|
+
}
|
package/lib/http.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { validateAliasName } from './alias-resolver.js'
|
|
2
|
+
function normalizeWebhookTestRequest(body) {
|
|
3
|
+
if (!body || typeof body !== "object") throw Object.assign(new Error("body is required"), { code: "INVALID_REQUEST" })
|
|
4
|
+
if (typeof body.url !== "string" || !body.url.trim()) throw Object.assign(new Error("url is required"), { code: "INVALID_REQUEST" })
|
|
5
|
+
return {
|
|
6
|
+
url: body.url.trim(),
|
|
7
|
+
secret: typeof body.secret === "string" ? body.secret.trim() : "",
|
|
8
|
+
}
|
|
9
|
+
}
|
|
1
10
|
import crypto from 'node:crypto'
|
|
2
11
|
import { validatePolicy } from './policy.js'
|
|
3
12
|
|
|
@@ -245,6 +254,16 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
245
254
|
if (route === 'aliases' && req.method === 'GET') {
|
|
246
255
|
return json(res, 200, { aliases: synchronizer.getAliases() })
|
|
247
256
|
}
|
|
257
|
+
if (route === 'aliases-resolve' && req.method === 'GET') {
|
|
258
|
+
try {
|
|
259
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1')
|
|
260
|
+
const alias = urlObj.searchParams.get('alias')
|
|
261
|
+
if (!alias) return json(res, 400, { error: { code: 'bad-request', message: 'alias query parameter is required' } })
|
|
262
|
+
return json(res, 200, synchronizer.resolveAlias(alias))
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return json(res, 400, { error: { code: error?.code ?? 'resolve-failed', message: error instanceof Error ? error.message : String(error) } })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
248
267
|
if (route === 'import') {
|
|
249
268
|
if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
|
|
250
269
|
try {
|
|
@@ -277,6 +296,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
277
296
|
: route === 'health' ? normalizeHealthRequest(body)
|
|
278
297
|
: route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
|
|
279
298
|
: route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
|
|
299
|
+
: route === 'webhook-test' ? normalizeWebhookTestRequest(body)
|
|
280
300
|
: route === 'rollback' ? normalizeHistoryRollbackRequest(body)
|
|
281
301
|
: normalizeRunRequest(body)
|
|
282
302
|
}
|
|
@@ -292,6 +312,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
292
312
|
: route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
|
|
293
313
|
: route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
|
|
294
314
|
: route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
|
|
315
|
+
: route === 'webhook-test' ? await synchronizer.testWebhook(options)
|
|
295
316
|
: route === 'rollback' ? await synchronizer.rollbackHistory(options)
|
|
296
317
|
: await synchronizer.run(options)
|
|
297
318
|
return json(res, 200, { ...result, providers: synchronizer.listProviders() })
|
|
@@ -309,6 +330,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
309
330
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/export`, handler: makeHandler('export') }),
|
|
310
331
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/import`, handler: makeHandler('import') }),
|
|
311
332
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases`, handler: makeHandler('aliases') }),
|
|
333
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases/resolve`, handler: makeHandler('aliases-resolve') }),
|
|
312
334
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
|
|
313
335
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
|
|
314
336
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
|
|
@@ -320,5 +342,6 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
320
342
|
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
|
|
321
343
|
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
|
|
322
344
|
ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
|
|
345
|
+
ctx.webServer.register({ kind: 'exact', path: BASE + '/webhook/test', handler: makeHandler('webhook-test') }),
|
|
323
346
|
]
|
|
324
347
|
}
|
package/lib/index.js
CHANGED
|
@@ -26,6 +26,11 @@ export const Config = Schema.object({
|
|
|
26
26
|
circuitBreakerCooldownMinutes: Schema.number().default(5),
|
|
27
27
|
historyLimit: Schema.number().default(50),
|
|
28
28
|
notificationLimit: Schema.number().default(50),
|
|
29
|
+
diffWebhookUrl: Schema.string().default(),
|
|
30
|
+
diffWebhookSecret: Schema.string().default(),
|
|
31
|
+
notifyOnModelAdded: Schema.boolean().default(true),
|
|
32
|
+
notifyOnModelDeprecated: Schema.boolean().default(true),
|
|
33
|
+
notifyOnPriceChanged: Schema.boolean().default(true),
|
|
29
34
|
notifications: Schema.array(Schema.object({
|
|
30
35
|
id: Schema.string().required(),
|
|
31
36
|
fingerprint: Schema.string().required(),
|
|
@@ -65,8 +70,13 @@ export const Config = Schema.object({
|
|
|
65
70
|
autoApply: Schema.boolean().default(false),
|
|
66
71
|
})).default([]),
|
|
67
72
|
aliases: Schema.dict(Schema.object({
|
|
68
|
-
provider: Schema.string()
|
|
69
|
-
model: Schema.string()
|
|
73
|
+
provider: Schema.string(),
|
|
74
|
+
model: Schema.string(),
|
|
75
|
+
targets: Schema.array(Schema.object({
|
|
76
|
+
provider: Schema.string().required(),
|
|
77
|
+
model: Schema.string().required(),
|
|
78
|
+
})),
|
|
79
|
+
strategy: Schema.string(),
|
|
70
80
|
updatedAt: Schema.number().default(0),
|
|
71
81
|
}).loose()).default({}),
|
|
72
82
|
modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
|
package/lib/models.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lookupReferencePricing } from './pricing-database.js'
|
|
1
2
|
const FIELD_ALIASES = Object.freeze({
|
|
2
3
|
contextWindow: [
|
|
3
4
|
'contextWindow', 'context_window', 'contextLength', 'context_length',
|
|
@@ -292,7 +293,7 @@ export function normalizeModel(provider, row) {
|
|
|
292
293
|
const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
|
|
293
294
|
const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
|
|
294
295
|
const capabilities = normalizeCapabilities(row)
|
|
295
|
-
const pricing = normalizePricing(row)
|
|
296
|
+
const pricing = normalizePricing(row) ?? lookupReferencePricing(effectiveProvider || provider, id)
|
|
296
297
|
const tags = normalizeTags(row)
|
|
297
298
|
const aliases = normalizeAliases(row, id)
|
|
298
299
|
const lifecycle = normalizeLifecycle(row)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function cleanTokenPrice(val) {
|
|
2
|
+
if (typeof val !== 'number' || !Number.isFinite(val)) return 0
|
|
3
|
+
return Math.round((val / 1000000) * 1e12) / 1e12
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function perMillion(inputUSD, outputUSD, extra = {}) {
|
|
7
|
+
const res = {
|
|
8
|
+
inputPerToken: cleanTokenPrice(inputUSD),
|
|
9
|
+
outputPerToken: cleanTokenPrice(outputUSD),
|
|
10
|
+
currency: 'USD',
|
|
11
|
+
source: 'reference',
|
|
12
|
+
}
|
|
13
|
+
if (extra.cacheReadUSD !== undefined) {
|
|
14
|
+
res.cacheReadPerToken = cleanTokenPrice(extra.cacheReadUSD)
|
|
15
|
+
}
|
|
16
|
+
if (extra.cacheWriteUSD !== undefined) {
|
|
17
|
+
res.cacheWritePerToken = cleanTokenPrice(extra.cacheWriteUSD)
|
|
18
|
+
}
|
|
19
|
+
return Object.freeze(res)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const REFERENCE_PRICING = Object.freeze({
|
|
23
|
+
openai: {
|
|
24
|
+
'gpt-4o': perMillion(2.5, 10),
|
|
25
|
+
'gpt-4o-mini': perMillion(0.15, 0.6),
|
|
26
|
+
'gpt-4-turbo': perMillion(10, 30),
|
|
27
|
+
'gpt-4': perMillion(30, 60),
|
|
28
|
+
'gpt-3.5-turbo': perMillion(0.5, 1.5),
|
|
29
|
+
'o1': perMillion(15, 60),
|
|
30
|
+
'o1-preview': perMillion(15, 60),
|
|
31
|
+
'o1-mini': perMillion(1.1, 4.4),
|
|
32
|
+
'o3-mini': perMillion(1.1, 4.4),
|
|
33
|
+
'chatgpt-4o-latest': perMillion(5, 15),
|
|
34
|
+
},
|
|
35
|
+
anthropic: {
|
|
36
|
+
'claude-3-7-sonnet': perMillion(3, 15),
|
|
37
|
+
'claude-3-5-sonnet': perMillion(3, 15),
|
|
38
|
+
'claude-3-5-haiku': perMillion(0.8, 4),
|
|
39
|
+
'claude-3-opus': perMillion(15, 75),
|
|
40
|
+
'claude-3-haiku': perMillion(0.25, 1.25),
|
|
41
|
+
},
|
|
42
|
+
groq: {
|
|
43
|
+
'llama-3.3-70b-versatile': perMillion(0.59, 0.79),
|
|
44
|
+
'llama-3.3-70b-specdec': perMillion(0.59, 0.99),
|
|
45
|
+
'llama-3.1-70b-versatile': perMillion(0.59, 0.79),
|
|
46
|
+
'llama-3.1-8b-instant': perMillion(0.05, 0.08),
|
|
47
|
+
'llama3-70b-8192': perMillion(0.59, 0.79),
|
|
48
|
+
'llama3-8b-8192': perMillion(0.05, 0.08),
|
|
49
|
+
'mixtral-8x7b-32768': perMillion(0.24, 0.24),
|
|
50
|
+
'gemma2-9b-it': perMillion(0.2, 0.2),
|
|
51
|
+
'deepseek-r1-distill-llama-70b': perMillion(0.75, 0.99),
|
|
52
|
+
'deepseek-r1-distill-qwen-32b': perMillion(0.59, 0.79),
|
|
53
|
+
'qwen-2.5-32b': perMillion(0.59, 0.79),
|
|
54
|
+
'qwen-2.5-coder-32b': perMillion(0.59, 0.79),
|
|
55
|
+
},
|
|
56
|
+
mistral: {
|
|
57
|
+
'mistral-large': perMillion(2, 6),
|
|
58
|
+
'mistral-small': perMillion(0.1, 0.3),
|
|
59
|
+
'codestral': perMillion(0.3, 0.9),
|
|
60
|
+
'ministral-8b': perMillion(0.1, 0.1),
|
|
61
|
+
'ministral-3b': perMillion(0.04, 0.04),
|
|
62
|
+
'pixtral-large': perMillion(2, 6),
|
|
63
|
+
'pixtral-12b': perMillion(0.15, 0.15),
|
|
64
|
+
'open-mistral-7b': perMillion(0.2, 0.2),
|
|
65
|
+
'open-mixtral-8x7b': perMillion(0.7, 0.7),
|
|
66
|
+
},
|
|
67
|
+
xai: {
|
|
68
|
+
'grok-2': perMillion(2, 10),
|
|
69
|
+
'grok-2-mini': perMillion(0.2, 1),
|
|
70
|
+
'grok-2-vision': perMillion(2, 10),
|
|
71
|
+
'grok-beta': perMillion(5, 15),
|
|
72
|
+
},
|
|
73
|
+
deepseek: {
|
|
74
|
+
'deepseek-chat': perMillion(0.14, 0.28, { cacheReadUSD: 0.014 }),
|
|
75
|
+
'deepseek-reasoner': perMillion(0.55, 2.19, { cacheReadUSD: 0.14 }),
|
|
76
|
+
'deepseek-coder': perMillion(0.14, 0.28),
|
|
77
|
+
},
|
|
78
|
+
google: {
|
|
79
|
+
'gemini-2.0-flash': perMillion(0.1, 0.4),
|
|
80
|
+
'gemini-1.5-pro': perMillion(1.25, 5),
|
|
81
|
+
'gemini-1.5-flash': perMillion(0.075, 0.3),
|
|
82
|
+
'gemini-1.5-flash-8b': perMillion(0.0375, 0.15),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function cleanId(id) {
|
|
87
|
+
if (typeof id !== 'string') return ''
|
|
88
|
+
return id
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/^models\//, '')
|
|
92
|
+
.replace(/:latest$/, '')
|
|
93
|
+
.replace(/-latest$/, '')
|
|
94
|
+
.replace(/-(202\d{5}|202\d-\d{2}-\d{2}|\d{4})$/, '')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function lookupReferencePricing(provider, modelId) {
|
|
98
|
+
if (typeof modelId !== 'string' || !modelId.trim()) return undefined
|
|
99
|
+
const provKey = String(provider || '').toLowerCase().trim()
|
|
100
|
+
const rawId = modelId.toLowerCase().trim()
|
|
101
|
+
const cleaned = cleanId(rawId)
|
|
102
|
+
|
|
103
|
+
// 1. Direct check in provider table
|
|
104
|
+
const table = REFERENCE_PRICING[provKey]
|
|
105
|
+
if (table) {
|
|
106
|
+
if (table[rawId]) return { ...table[rawId] }
|
|
107
|
+
if (table[cleaned]) return { ...table[cleaned] }
|
|
108
|
+
for (const [key, val] of Object.entries(table)) {
|
|
109
|
+
if (rawId.startsWith(key) || cleaned.startsWith(key)) return { ...val }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Vendor prefix check (e.g. "openai/gpt-4o" or "anthropic/claude-3-5-sonnet")
|
|
114
|
+
const slashIdx = rawId.indexOf('/')
|
|
115
|
+
if (slashIdx > 0) {
|
|
116
|
+
const subProv = rawId.slice(0, slashIdx)
|
|
117
|
+
const subId = rawId.slice(slashIdx + 1)
|
|
118
|
+
const subCleaned = cleanId(subId)
|
|
119
|
+
const subTable = REFERENCE_PRICING[subProv]
|
|
120
|
+
if (subTable) {
|
|
121
|
+
if (subTable[subId]) return { ...subTable[subId] }
|
|
122
|
+
if (subTable[subCleaned]) return { ...subTable[subCleaned] }
|
|
123
|
+
for (const [key, val] of Object.entries(subTable)) {
|
|
124
|
+
if (subId.startsWith(key) || subCleaned.startsWith(key)) return { ...val }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Fallback search across all tables if cleaned matches well-known key
|
|
130
|
+
for (const [p, t] of Object.entries(REFERENCE_PRICING)) {
|
|
131
|
+
if (t[cleaned]) return { ...t[cleaned] }
|
|
132
|
+
for (const [key, val] of Object.entries(t)) {
|
|
133
|
+
if (cleaned.startsWith(key)) return { ...val }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolveAlias as resolveSmartAlias, validateAliasName } from './alias-resolver.js'
|
|
2
|
+
|
|
3
|
+
export function createConfigTransfer({
|
|
4
|
+
getConfig = () => ({}),
|
|
5
|
+
saveConfig = async () => {},
|
|
6
|
+
listProviders = () => [],
|
|
7
|
+
validatePolicy,
|
|
8
|
+
isCircuitOpen = () => false,
|
|
9
|
+
getHealthResults = () => [],
|
|
10
|
+
getModelProbes = () => ({}),
|
|
11
|
+
} = {}) {
|
|
2
12
|
function exportConfig() {
|
|
3
13
|
const config = getConfig() ?? {}
|
|
4
14
|
const pmap = {}
|
|
@@ -56,8 +66,15 @@ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = asyn
|
|
|
56
66
|
if (data.aliases && typeof data.aliases === 'object') {
|
|
57
67
|
const currentAliases = structuredClone(config.aliases ?? {})
|
|
58
68
|
for (const [k, v] of Object.entries(data.aliases)) {
|
|
59
|
-
if (
|
|
60
|
-
|
|
69
|
+
if (validateAliasName(k) && v && typeof v === 'object') {
|
|
70
|
+
const entry = { updatedAt: Date.now() }
|
|
71
|
+
if (Array.isArray(v.targets)) entry.targets = v.targets
|
|
72
|
+
else if (v.strategy) entry.strategy = v.strategy
|
|
73
|
+
else if (v.provider && v.model) {
|
|
74
|
+
entry.provider = v.provider
|
|
75
|
+
entry.model = v.model
|
|
76
|
+
}
|
|
77
|
+
currentAliases[k.trim()] = entry
|
|
61
78
|
importedAliases.push(k.trim())
|
|
62
79
|
}
|
|
63
80
|
}
|
|
@@ -83,19 +100,27 @@ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = asyn
|
|
|
83
100
|
return config.aliases ?? {}
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
async function setAlias({ alias, provider, model } = {}) {
|
|
87
|
-
if (
|
|
88
|
-
throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
|
|
103
|
+
async function setAlias({ alias, provider, model, targets, strategy } = {}) {
|
|
104
|
+
if (!validateAliasName(alias)) {
|
|
105
|
+
throw Object.assign(new Error('alias must be 1-64 alphanumeric characters, @ or _-.'), { code: 'INVALID_ALIAS' })
|
|
89
106
|
}
|
|
90
107
|
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
108
|
const config = getConfig() ?? {}
|
|
95
109
|
const currentAliases = structuredClone(config.aliases ?? {})
|
|
96
|
-
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(targets) && targets.length > 0) {
|
|
112
|
+
currentAliases[trimmedAlias] = { targets, updatedAt: Date.now() }
|
|
113
|
+
} else if (strategy && typeof strategy === 'string') {
|
|
114
|
+
currentAliases[trimmedAlias] = { strategy, updatedAt: Date.now() }
|
|
115
|
+
} else {
|
|
116
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
117
|
+
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
118
|
+
currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
97
122
|
await saveConfig({ aliases: currentAliases })
|
|
98
|
-
return { alias: trimmedAlias,
|
|
123
|
+
return { alias: trimmedAlias, ...currentAliases[trimmedAlias], aliases: currentAliases }
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
async function deleteAlias({ alias } = {}) {
|
|
@@ -108,11 +133,23 @@ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = asyn
|
|
|
108
133
|
return { alias: alias.trim(), deleted: true, aliases: currentAliases }
|
|
109
134
|
}
|
|
110
135
|
|
|
136
|
+
function resolveAlias(aliasName) {
|
|
137
|
+
const config = getConfig() ?? {}
|
|
138
|
+
return resolveSmartAlias(aliasName, {
|
|
139
|
+
aliases: config.aliases ?? {},
|
|
140
|
+
modelCatalogs: config.modelCatalogs ?? {},
|
|
141
|
+
isCircuitOpen,
|
|
142
|
+
healthResults: typeof getHealthResults === 'function' ? getHealthResults() : [],
|
|
143
|
+
modelProbes: typeof getModelProbes === 'function' ? getModelProbes() : {},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
return {
|
|
112
148
|
exportConfig,
|
|
113
149
|
importConfig,
|
|
114
150
|
getAliases,
|
|
115
151
|
setAlias,
|
|
116
152
|
deleteAlias,
|
|
153
|
+
resolveAlias,
|
|
117
154
|
}
|
|
118
155
|
}
|
package/lib/synchronizer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dispatchDiffNotifications, sendWebhookNotification } from "./diff-notifier.js"
|
|
1
2
|
import { listApiKeyProviders } from './inventory.js'
|
|
2
3
|
import { createProviderAdapterRegistry } from './adapter-registry.js'
|
|
3
4
|
import { createCredentialResolver } from './credentials.js'
|
|
@@ -413,6 +414,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
413
414
|
result.historyError = errorMessage(error)
|
|
414
415
|
}
|
|
415
416
|
}
|
|
417
|
+
if (!dryRun || options.forceNotify) {
|
|
418
|
+
try {
|
|
419
|
+
const notifyResult = await dispatchDiffNotifications(ctx, config, results)
|
|
420
|
+
if (notifyResult?.dispatched) {
|
|
421
|
+
result.diffNotifications = notifyResult
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (typeof ctx?.logger?.debug === "function") {
|
|
425
|
+
ctx.logger.debug("[dsh-model-sync] dispatchDiffNotifications error:", err)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
416
429
|
result.notifications = notificationLedger()
|
|
417
430
|
lastRun = result
|
|
418
431
|
return result
|
|
@@ -524,7 +537,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
524
537
|
if (provider === 'commandcode') {
|
|
525
538
|
if (typeof settings?.mutate === 'function') {
|
|
526
539
|
await settings.mutate('llm-commandcode', [
|
|
527
|
-
{ op: 'set', path: ['visibleModels'], value: selectedModels }
|
|
540
|
+
{ op: 'set', path: ['visibleModels'], value: selectedModels.length ? selectedModels : nextModels.map((m) => m.id) }
|
|
528
541
|
])
|
|
529
542
|
}
|
|
530
543
|
if (section?.providers?.commandcode && typeof settings?.replace === 'function') {
|
|
@@ -551,12 +564,15 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
551
564
|
saveConfig,
|
|
552
565
|
listProviders: providers,
|
|
553
566
|
validatePolicy,
|
|
567
|
+
isCircuitOpen: (p) => Boolean(circuits.get(p)?.blockedUntil > Date.now()),
|
|
568
|
+
getHealthResults: () => lastHealth?.results ?? [],
|
|
554
569
|
})
|
|
555
570
|
const exportConfig = transfer.exportConfig
|
|
556
571
|
const importConfig = transfer.importConfig
|
|
557
572
|
const getAliases = transfer.getAliases
|
|
558
573
|
const setAlias = transfer.setAlias
|
|
559
574
|
const deleteAlias = transfer.deleteAlias
|
|
575
|
+
const resolveAlias = transfer.resolveAlias
|
|
560
576
|
|
|
561
577
|
return Object.freeze({
|
|
562
578
|
listProviders: providers,
|
|
@@ -576,12 +592,14 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
576
592
|
getAliases,
|
|
577
593
|
setAlias,
|
|
578
594
|
deleteAlias,
|
|
595
|
+
resolveAlias,
|
|
579
596
|
credentialDiagnostics,
|
|
580
597
|
history,
|
|
581
598
|
report: () => structuredClone(lastReport),
|
|
582
599
|
notifications: notificationLedger,
|
|
583
600
|
updateNotification: updateNotificationState,
|
|
584
601
|
rollbackHistory,
|
|
602
|
+
testWebhook: async ({ url, secret }) => sendWebhookNotification(url, secret, { event: model-sync.test, timestamp: Date.now(), test: true }),
|
|
585
603
|
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
|
|
586
604
|
})
|
|
587
605
|
}
|