@goodandready/dsh-model-sync 0.4.9 → 0.5.1

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 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,216 @@
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 && isVirtualAlias(clean)) {
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
+ if (!VIRTUAL_STRATEGIES.includes(strategy)) {
121
+ throw Object.assign(new Error(`Invalid strategy: ${strategy}`), { code: 'INVALID_STRATEGY' })
122
+ }
123
+ const available = []
124
+ for (const [provider, models] of Object.entries(modelCatalogs)) {
125
+ if (isCircuitOpen(provider)) continue
126
+ if (!Array.isArray(models)) continue
127
+ for (const m of models) {
128
+ if (!m?.id) continue
129
+ available.push({ provider, model: m })
130
+ }
131
+ }
132
+
133
+ if (available.length === 0) {
134
+ return { alias: clean, strategy, resolved: null, error: 'NO_HEALTHY_MODELS' }
135
+ }
136
+
137
+ if (strategy === 'fastest') {
138
+ const scored = available.map((item) => ({
139
+ ...item,
140
+ latencyMs: estimateLatency(item.provider, item.model, modelProbes),
141
+ }))
142
+ scored.sort((a, b) => a.latencyMs - b.latencyMs)
143
+ const best = scored[0]
144
+ return {
145
+ alias: clean,
146
+ strategy: 'fastest',
147
+ provider: best.provider,
148
+ model: best.model.id,
149
+ name: best.model.name || best.model.id,
150
+ latencyMs: best.latencyMs,
151
+ fallbackUsed: false,
152
+ reason: 'lowest_latency',
153
+ }
154
+ }
155
+
156
+ if (strategy === 'cheapest') {
157
+ const priced = available.filter((item) => {
158
+ const p = item.model?.pricing
159
+ return p && (Number.isFinite(p.inputPerToken) || Number.isFinite(p.outputPerToken))
160
+ }).map((item) => {
161
+ const p = item.model.pricing
162
+ const cost = (p.inputPerToken || 0) + (p.outputPerToken || 0)
163
+ return { ...item, cost }
164
+ })
165
+
166
+ if (priced.length > 0) {
167
+ priced.sort((a, b) => a.cost - b.cost)
168
+ const best = priced[0]
169
+ return {
170
+ alias: clean,
171
+ strategy: 'cheapest',
172
+ provider: best.provider,
173
+ model: best.model.id,
174
+ name: best.model.name || best.model.id,
175
+ pricing: best.model.pricing,
176
+ costPerToken: best.cost,
177
+ fallbackUsed: false,
178
+ reason: 'lowest_price',
179
+ }
180
+ }
181
+ // If no pricing metadata available, pick a mini/flash model as fallback
182
+ const fallback = available.find((a) => /(mini|flash|small|lite)/i.test(a.model.id)) || available[0]
183
+ return {
184
+ alias: clean,
185
+ strategy: 'cheapest',
186
+ provider: fallback.provider,
187
+ model: fallback.model.id,
188
+ name: fallback.model.name || fallback.model.id,
189
+ fallbackUsed: true,
190
+ reason: 'heuristic_cheapest_fallback',
191
+ }
192
+ }
193
+
194
+ if (strategy === 'best-code') {
195
+ const scored = available.map((item) => ({
196
+ ...item,
197
+ score: modelScoreForCode(item.provider, item.model),
198
+ }))
199
+ scored.sort((a, b) => b.score - a.score)
200
+ const best = scored[0]
201
+ return {
202
+ alias: clean,
203
+ strategy: 'best-code',
204
+ provider: best.provider,
205
+ model: best.model.id,
206
+ name: best.model.name || best.model.id,
207
+ capabilities: best.model.capabilities,
208
+ score: best.score,
209
+ fallbackUsed: false,
210
+ reason: 'top_coding_capability',
211
+ }
212
+ }
213
+ }
214
+
215
+ throw Object.assign(new Error("Unknown alias: " + clean), { code: 'UNKNOWN_ALIAS' })
216
+ }
package/lib/client.js CHANGED
@@ -5,6 +5,14 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
 
8
+ let ChevronIcon = null
9
+ try {
10
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
11
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
12
+ } catch {
13
+ /* Optional core chevron primitive not available; SVG fallback used */
14
+ }
15
+
8
16
  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
17
  '.dms-card-open{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-border-l2)}' +
10
18
  '.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}' +
@@ -202,6 +210,18 @@ window.__ModuleLoader__.load({
202
210
  scheduleEnabled: 'Enable background schedule',
203
211
  intervalMinutes: 'Interval (minutes)',
204
212
  autoApplySchedule: 'Auto-apply changes',
213
+ diffWebhooks: 'External Diff Webhooks',
214
+ diffWebhookUrl: 'Webhook URL',
215
+ diffWebhookSecret: 'Webhook Secret (optional)',
216
+ notifyOnModelAdded: 'Notify on new models',
217
+ notifyOnModelDeprecated: 'Notify on deprecated / removed models',
218
+ notifyOnPriceChanged: 'Notify on price changes',
219
+ saveWebhook: 'Save Webhook Settings',
220
+ testWebhook: 'Test Webhook',
221
+ webhookTesting: 'Testing...',
222
+ webhookSuccess: 'Webhook test succeeded',
223
+ webhookFailed: 'Webhook test failed: %s',
224
+ webhookSaved: 'Webhook settings saved',
205
225
  saveScheduler: 'Save scheduler',
206
226
  schedulerSaved: 'Saved',
207
227
  scopeUnavailable: 'Settings service unavailable.',
@@ -222,6 +242,13 @@ window.__ModuleLoader__.load({
222
242
  importError: 'Import failed: %s',
223
243
  importPlaceholder: 'Paste JSON configuration here…',
224
244
  modelAliases: 'Model Aliases',
245
+ virtualAliases: 'Virtual Smart Aliases',
246
+ resolveAlias: 'Test Resolve',
247
+ resolvingAlias: 'Resolving...',
248
+ virtualAliasDesc: 'Dynamic aliases route to optimal models based on latency, pricing, and health probes.',
249
+ aliasFastDesc: 'Fastest response time among healthy models',
250
+ aliasCheapDesc: 'Lowest token pricing available',
251
+ aliasBestCodeDesc: 'Top coding & reasoning capabilities',
225
252
  aliasPlaceholder: 'Alias (e.g. gpt-4o-latest)',
226
253
  targetModel: 'Target model',
227
254
  addAlias: 'Save alias',
@@ -333,6 +360,18 @@ window.__ModuleLoader__.load({
333
360
  scheduleEnabled: '开启后台定时同步',
334
361
  intervalMinutes: '间隔时间(分钟)',
335
362
  autoApplySchedule: '自动应用变更',
363
+ diffWebhooks: '外部差异通知 Webhook',
364
+ diffWebhookUrl: 'Webhook 地址',
365
+ diffWebhookSecret: 'Webhook 密钥(可选)',
366
+ notifyOnModelAdded: '新模型添加时通知',
367
+ notifyOnModelDeprecated: '模型废弃/移除时通知',
368
+ notifyOnPriceChanged: '模型价格变更时通知',
369
+ saveWebhook: '保存 Webhook 设置',
370
+ testWebhook: '测试 Webhook',
371
+ webhookTesting: '正在测试...',
372
+ webhookSuccess: 'Webhook 测试成功',
373
+ webhookFailed: 'Webhook 测试失败:%s',
374
+ webhookSaved: 'Webhook 设置已保存',
336
375
  saveScheduler: '保存计划',
337
376
  schedulerSaved: '已保存',
338
377
  scopeUnavailable: '设置服务不可用。',
@@ -353,6 +392,13 @@ window.__ModuleLoader__.load({
353
392
  importError: '导入失败:%s',
354
393
  importPlaceholder: '在此粘贴 JSON 配置内容…',
355
394
  modelAliases: '模型别名映射',
395
+ virtualAliases: '智能虚拟别名',
396
+ resolveAlias: '解析测试',
397
+ resolvingAlias: '正在解析...',
398
+ virtualAliasDesc: '动态别名根据实时延迟、定价与可用性探测动态路由至最优模型。',
399
+ aliasFastDesc: '所有健康模型中响应延迟最低',
400
+ aliasCheapDesc: 'Token 单价最低的可用模型',
401
+ aliasBestCodeDesc: '代码与推理能力评分最优',
356
402
  aliasPlaceholder: '别名(例如 gpt-4o-latest)',
357
403
  targetModel: '目标模型',
358
404
  addAlias: '保存别名',
@@ -519,6 +565,8 @@ window.__ModuleLoader__.load({
519
565
  const [aliasDraft, setAliasDraft] = React.useState({ alias: '', provider: '', model: '' })
520
566
  const [aliasMsg, setAliasMsg] = React.useState('')
521
567
  const [aliasErr, setAliasErr] = React.useState('')
568
+ const [resolvedAliases, setResolvedAliases] = React.useState({})
569
+ const [resolvingKey, setResolvingKey] = React.useState('')
522
570
 
523
571
  const [importText, setImportText] = React.useState('')
524
572
  const [importMsg, setImportMsg] = React.useState('')
@@ -534,6 +582,10 @@ window.__ModuleLoader__.load({
534
582
  const snapStatus = snapshot?.status || 'loading'
535
583
  const stored = snapshot?.value || {}
536
584
  const [schedDraft, setSchedDraft] = React.useState(null)
585
+ const [webhookDraft, setWebhookDraft] = React.useState(null)
586
+ const [webhookMsg, setWebhookMsg] = React.useState('')
587
+ const [webhookErr, setWebhookErr] = React.useState('')
588
+ const [webhookTesting, setWebhookTesting] = React.useState(false)
537
589
  const [schedMsg, setSchedMsg] = React.useState('')
538
590
  const [schedErr, setSchedErr] = React.useState('')
539
591
 
@@ -543,6 +595,48 @@ window.__ModuleLoader__.load({
543
595
  }
544
596
  }, [snapStatus, stored, schedDraft])
545
597
 
598
+ const saveWebhook = async () => {
599
+ if (!scope || !webhookDraft) return
600
+ setBusy(true); setWebhookErr(''); setWebhookMsg('')
601
+ try {
602
+ await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
603
+ await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
604
+ await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
605
+ await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
606
+ await scope.set('notifyOnPriceChanged', !!webhookDraft.notifyOnPriceChanged)
607
+ setWebhookMsg(t.webhookSaved)
608
+ } catch (e) {
609
+ setWebhookErr(String(e?.message ?? e))
610
+ } finally {
611
+ setBusy(false)
612
+ }
613
+ }
614
+
615
+ const testWebhook = async () => {
616
+ if (!webhookDraft?.diffWebhookUrl) return
617
+ setWebhookTesting(true); setWebhookErr(''); setWebhookMsg('')
618
+ try {
619
+ const res = await fetch(`${API}/webhook/test`, {
620
+ method: 'POST',
621
+ headers: { 'content-type': 'application/json' },
622
+ body: JSON.stringify({
623
+ url: webhookDraft.diffWebhookUrl,
624
+ secret: webhookDraft.diffWebhookSecret || '',
625
+ }),
626
+ })
627
+ const body = await res.json()
628
+ if (res.ok && body.ok) {
629
+ setWebhookMsg(t.webhookSuccess)
630
+ } else {
631
+ setWebhookErr((t.webhookFailed || 'Webhook test failed: %s').replace('%s', body.error || `HTTP ${body.status || res.status}`))
632
+ }
633
+ } catch (e) {
634
+ setWebhookErr((t.webhookFailed || 'Webhook test failed: %s').replace('%s', String(e?.message ?? e)))
635
+ } finally {
636
+ setWebhookTesting(false)
637
+ }
638
+ }
639
+
546
640
  const saveScheduler = async () => {
547
641
  if (!scope || !schedDraft) return
548
642
  setBusy(true); setSchedErr(''); setSchedMsg('')
@@ -918,6 +1012,55 @@ window.__ModuleLoader__.load({
918
1012
  const visibleChoices = filteredChoices.slice(0, pageSize)
919
1013
  const button = (label, onClick, variant) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' + (variant ? ' dms-btn-' + variant : '') }, label)
920
1014
 
1015
+
1016
+ const renderWebhooks = () => {
1017
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' } },
1018
+ snapStatus === 'loading' ? React.createElement('small', null, t.loading) : null,
1019
+ snapStatus === 'unavailable' ? React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.scopeUnavailable) : null,
1020
+ webhookDraft ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 } },
1021
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
1022
+ React.createElement('span', { style: { minWidth: 160 } }, t.diffWebhookUrl + ':'),
1023
+ React.createElement('input', {
1024
+ type: 'url',
1025
+ placeholder: 'https://example.com/webhook',
1026
+ value: webhookDraft.diffWebhookUrl,
1027
+ onChange: (e) => setWebhookDraft((d) => ({ ...d, diffWebhookUrl: e.target.value })),
1028
+ disabled: busy || webhookTesting,
1029
+ 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' }
1030
+ })
1031
+ ),
1032
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } },
1033
+ React.createElement('span', { style: { minWidth: 160 } }, t.diffWebhookSecret + ':'),
1034
+ React.createElement('input', {
1035
+ type: 'password',
1036
+ placeholder: '••••••••',
1037
+ value: webhookDraft.diffWebhookSecret,
1038
+ onChange: (e) => setWebhookDraft((d) => ({ ...d, diffWebhookSecret: e.target.value })),
1039
+ disabled: busy || webhookTesting,
1040
+ 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' }
1041
+ })
1042
+ ),
1043
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
1044
+ React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnModelAdded, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnModelAdded: e.target.checked })), disabled: busy }),
1045
+ t.notifyOnModelAdded
1046
+ ),
1047
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
1048
+ React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnModelDeprecated, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnModelDeprecated: e.target.checked })), disabled: busy }),
1049
+ t.notifyOnModelDeprecated
1050
+ ),
1051
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
1052
+ React.createElement('input', { type: 'checkbox', checked: !!webhookDraft.notifyOnPriceChanged, onChange: (e) => setWebhookDraft((d) => ({ ...d, notifyOnPriceChanged: e.target.checked })), disabled: busy }),
1053
+ t.notifyOnPriceChanged
1054
+ ),
1055
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } },
1056
+ button(t.saveWebhook, saveWebhook, 'primary'),
1057
+ button(webhookTesting ? t.webhookTesting : t.testWebhook, testWebhook, 'secondary'),
1058
+ webhookMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, webhookMsg) : null,
1059
+ webhookErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, webhookErr) : null
1060
+ )
1061
+ ) : null
1062
+ )
1063
+ }
921
1064
  const renderNotifications = () => {
922
1065
  const entries = status?.notifications ?? []
923
1066
  if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
@@ -945,12 +1088,37 @@ window.__ModuleLoader__.load({
945
1088
  const renderAliases = () => {
946
1089
  const aliasMap = status?.aliases ?? {}
947
1090
  const entries = Object.entries(aliasMap)
948
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' } },
1091
+ const smartPresets = [
1092
+ { name: '@fast', label: t.aliasFastDesc },
1093
+ { name: '@cheap', label: t.aliasCheapDesc },
1094
+ { name: '@best-code', label: t.aliasBestCodeDesc },
1095
+ ]
1096
+
1097
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' } },
1098
+ React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 2 } }, t.virtualAliasDesc),
1099
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
1100
+ smartPresets.map((p) => {
1101
+ const res = resolvedAliases[p.name]
1102
+ const isResolving = resolvingKey === p.name
1103
+ 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)' } },
1104
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1105
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
1106
+ React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13, color: 'var(--dsw-alias-state-brand-primary)' } }, p.name),
1107
+ React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, p.label)
1108
+ ),
1109
+ button(isResolving ? t.resolvingAlias : t.resolveAlias, () => resolveSmartAlias(p.name), 'secondary')
1110
+ ),
1111
+ res ? (res.error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, res.error)
1112
+ : 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
1113
+ )
1114
+ })
1115
+ ),
949
1116
  entries.length === 0 ? React.createElement('small', { style: { display: 'block', marginTop: 4 } }, t.noAliases) : entries.map(([aliasName, data]) => {
1117
+ const targetText = data.targets ? `${data.targets.length} fallback targets` : data.strategy ? `strategy: ${data.strategy}` : `${data.provider} / ${data.model}`
950
1118
  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
1119
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
952
1120
  React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13 } }, aliasName),
953
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, `→ ${data.provider} / ${data.model}`)
1121
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 } }, `→ ${targetText}`)
954
1122
  ),
955
1123
  button(t.removeAlias, () => deleteAlias(aliasName), 'danger')
956
1124
  )
@@ -1212,6 +1380,10 @@ window.__ModuleLoader__.load({
1212
1380
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.notifications)),
1213
1381
  renderNotifications()
1214
1382
  ),
1383
+ React.createElement('div', { className: 'dms-section-card dms-section' },
1384
+ React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.diffWebhooks)),
1385
+ renderWebhooks()
1386
+ ),
1215
1387
  React.createElement('div', { className: 'dms-section-card dms-section' },
1216
1388
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.credentials)),
1217
1389
  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}` : ''}`))))
@@ -1261,9 +1433,11 @@ window.__ModuleLoader__.load({
1261
1433
  React.createElement('span', { className: 'dms-card-description' }, t.desc)
1262
1434
  ),
1263
1435
  cardBadge ? React.createElement('span', { style: { background: 'var(--dsw-alias-state-business-primary)', color: 'white', padding: '2px 6px', borderRadius: 6, fontSize: 11, marginLeft: 8 } }, `+${cardBadge}`) : null,
1264
- React.createElement('svg', { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true' },
1265
- React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' })
1266
- )
1436
+ ChevronIcon
1437
+ ? React.createElement(ChevronIcon, { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), 'aria-hidden': 'true' })
1438
+ : React.createElement('svg', { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true' },
1439
+ React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' })
1440
+ )
1267
1441
  ),
1268
1442
  (page || open) ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ErrorBoundary, null, React.createElement(ModelSyncSection, { ctx, includeIntro: false }))) : null
1269
1443
  )
@@ -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
 
@@ -137,8 +146,26 @@ export function normalizeBatchTryRequest(body) {
137
146
  export function normalizeAliasRequest(body) {
138
147
  if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
139
148
  if (typeof body.alias !== 'string' || !body.alias.trim()) throw new Error('alias is required')
140
- if (!/^[a-zA-Z0-9_\-.]{1,64}$/.test(body.alias.trim())) throw new Error('alias must be 1-64 alphanumeric characters or _-.')
149
+ if (!validateAliasName(body.alias)) throw new Error('alias must be 1-64 alphanumeric characters, @ or _-.')
141
150
  if (body.action === 'delete') return { action: 'delete', alias: body.alias.trim() }
151
+ if (Array.isArray(body.targets) && body.targets.length > 0) {
152
+ if (body.targets.length > 50) throw new Error('targets array is too long')
153
+ for (const t of body.targets) {
154
+ if (typeof t === 'string') {
155
+ if (!t || t.length > 256) throw new Error('target must be a short string')
156
+ } else if (t && typeof t === 'object') {
157
+ if (typeof t.provider !== 'string' || !t.provider || t.provider.length > 128) throw new Error('target provider must be a short string')
158
+ if (typeof t.model !== 'string' || !t.model || t.model.length > 256) throw new Error('target model must be a short string')
159
+ } else {
160
+ throw new Error('invalid target format')
161
+ }
162
+ }
163
+ return { action: 'set', alias: body.alias.trim(), targets: body.targets }
164
+ }
165
+ if (typeof body.strategy === 'string' && body.strategy.trim()) {
166
+ if (body.strategy.length > 64) throw new Error('strategy must be a short string')
167
+ return { action: 'set', alias: body.alias.trim(), strategy: body.strategy.trim() }
168
+ }
142
169
  if (typeof body.provider !== 'string' || !body.provider.trim() || body.provider.length > 128) throw new Error('provider must be a short string')
143
170
  if (typeof body.model !== 'string' || !body.model.trim() || body.model.length > 256) throw new Error('model must be a short string')
144
171
  return { action: 'set', alias: body.alias.trim(), provider: body.provider.trim(), model: body.model.trim() }
@@ -245,6 +272,17 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
245
272
  if (route === 'aliases' && req.method === 'GET') {
246
273
  return json(res, 200, { aliases: synchronizer.getAliases() })
247
274
  }
275
+ if (route === 'aliases-resolve') {
276
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
277
+ try {
278
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
279
+ const alias = urlObj.searchParams.get('alias')
280
+ if (!alias) return json(res, 400, { error: { code: 'bad-request', message: 'alias query parameter is required' } })
281
+ return json(res, 200, synchronizer.resolveAlias(alias))
282
+ } catch (error) {
283
+ return json(res, 400, { error: { code: error?.code ?? 'resolve-failed', message: error instanceof Error ? error.message : String(error) } })
284
+ }
285
+ }
248
286
  if (route === 'import') {
249
287
  if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
250
288
  try {
@@ -277,6 +315,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
277
315
  : route === 'health' ? normalizeHealthRequest(body)
278
316
  : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
279
317
  : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
318
+ : route === 'webhook-test' ? normalizeWebhookTestRequest(body)
280
319
  : route === 'rollback' ? normalizeHistoryRollbackRequest(body)
281
320
  : normalizeRunRequest(body)
282
321
  }
@@ -292,6 +331,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
292
331
  : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
293
332
  : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
294
333
  : route === 'notification-acknowledge' ? await synchronizer.updateNotification(options.id, 'acknowledged')
334
+ : route === 'webhook-test' ? await synchronizer.testWebhook(options)
295
335
  : route === 'rollback' ? await synchronizer.rollbackHistory(options)
296
336
  : await synchronizer.run(options)
297
337
  return json(res, 200, { ...result, providers: synchronizer.listProviders() })
@@ -309,6 +349,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
309
349
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/export`, handler: makeHandler('export') }),
310
350
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/import`, handler: makeHandler('import') }),
311
351
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases`, handler: makeHandler('aliases') }),
352
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases/resolve`, handler: makeHandler('aliases-resolve') }),
312
353
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
313
354
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
314
355
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
@@ -320,5 +361,6 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
320
361
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications', handler: makeHandler('notifications') }),
321
362
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
322
363
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
364
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/webhook/test', handler: makeHandler('webhook-test') }),
323
365
  ]
324
366
  }
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().required(),
69
- model: Schema.string().required(),
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,7 @@
1
1
  import { statusOfError } from './reliability.js'
2
+ import { historyEntry } from './history.js'
3
+ import { normalizeModels } from './models.js'
4
+ import { catalogPatch } from './reconcile.js'
2
5
 
3
6
  export const PIAI_NS = 'llm-pi-ai'
4
7
  export const DEFAULT_RELIABILITY = Object.freeze({
@@ -124,3 +127,38 @@ export function rotationRefs(settings, provider) {
124
127
  return []
125
128
  }
126
129
  }
130
+
131
+ export function refsFor(row, settings) {
132
+ const profile = profileOf(settings, row.provider)
133
+ const refs = [
134
+ row.apiKeyEnv,
135
+ profile.credentialRef,
136
+ ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
137
+ ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
138
+ ...rotationRefs(settings, row.provider),
139
+ ]
140
+ return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
141
+ }
142
+
143
+ export async function performHistoryRollback({ ctx, config, providers = [], historyId, provider } = {}) {
144
+ if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
145
+ if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
146
+ const entry = historyEntry(config.history, historyId)
147
+ if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
148
+ const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
149
+ if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
150
+ if (!providers.some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
151
+ const settings = settingsOf(ctx)
152
+ const section = settings?.get?.(PIAI_NS)
153
+ const descriptor = descriptorOf(settings, PIAI_NS)
154
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
155
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
156
+ }
157
+ const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
158
+ const target = normalizeModels(provider, snapshot.before)
159
+ if (JSON.stringify(current) === JSON.stringify(target)) {
160
+ return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
161
+ }
162
+ await replaceWithRetry(settings, PIAI_NS, (sec) => catalogPatch(sec, provider, target))
163
+ return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
164
+ }
@@ -1,4 +1,14 @@
1
- export function createConfigTransfer({ getConfig = () => ({}), saveConfig = async () => {}, listProviders = () => [], validatePolicy } = {}) {
1
+ import { resolveAlias as resolveSmartAlias, validateAliasName, VIRTUAL_STRATEGIES, isVirtualAlias } 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,23 @@ 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 (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() }
69
+ if (validateAliasName(k) && v && typeof v === 'object') {
70
+ const entry = { updatedAt: Date.now() }
71
+ if (Array.isArray(v.targets)) {
72
+ entry.targets = v.targets
73
+ } else if (v.strategy && typeof v.strategy === 'string') {
74
+ const strat = v.strategy.trim()
75
+ if (VIRTUAL_STRATEGIES.includes(strat)) {
76
+ entry.strategy = strat
77
+ entry.isVirtual = isVirtualAlias(k)
78
+ } else {
79
+ continue
80
+ }
81
+ } else if (v.provider && v.model) {
82
+ entry.provider = v.provider
83
+ entry.model = v.model
84
+ }
85
+ currentAliases[k.trim()] = entry
61
86
  importedAliases.push(k.trim())
62
87
  }
63
88
  }
@@ -83,19 +108,35 @@ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = asyn
83
108
  return config.aliases ?? {}
84
109
  }
85
110
 
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' })
111
+ async function setAlias({ alias, provider, model, targets, strategy } = {}) {
112
+ if (!validateAliasName(alias)) {
113
+ throw Object.assign(new Error('alias must be 1-64 alphanumeric characters, @ or _-.'), { code: 'INVALID_ALIAS' })
89
114
  }
90
115
  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
116
  const config = getConfig() ?? {}
95
117
  const currentAliases = structuredClone(config.aliases ?? {})
96
- currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
118
+
119
+ if (Array.isArray(targets) && targets.length > 0) {
120
+ currentAliases[trimmedAlias] = { targets, updatedAt: Date.now() }
121
+ } else if (strategy !== undefined && strategy !== null && strategy !== '') {
122
+ const cleanStrategy = typeof strategy === 'string' ? strategy.trim() : ''
123
+ if (!VIRTUAL_STRATEGIES.includes(cleanStrategy)) {
124
+ throw Object.assign(new Error(`Invalid strategy: ${strategy}. Supported: ${VIRTUAL_STRATEGIES.join(', ')}`), { code: 'INVALID_STRATEGY' })
125
+ }
126
+ currentAliases[trimmedAlias] = {
127
+ strategy: cleanStrategy,
128
+ isVirtual: isVirtualAlias(trimmedAlias),
129
+ updatedAt: Date.now(),
130
+ }
131
+ } else {
132
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
133
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
134
+ currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
135
+ }
136
+
137
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
97
138
  await saveConfig({ aliases: currentAliases })
98
- return { alias: trimmedAlias, provider, model, aliases: currentAliases }
139
+ return { alias: trimmedAlias, ...currentAliases[trimmedAlias], aliases: currentAliases }
99
140
  }
100
141
 
101
142
  async function deleteAlias({ alias } = {}) {
@@ -108,11 +149,23 @@ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = asyn
108
149
  return { alias: alias.trim(), deleted: true, aliases: currentAliases }
109
150
  }
110
151
 
152
+ function resolveAlias(aliasName) {
153
+ const config = getConfig() ?? {}
154
+ return resolveSmartAlias(aliasName, {
155
+ aliases: config.aliases ?? {},
156
+ modelCatalogs: config.modelCatalogs ?? {},
157
+ isCircuitOpen,
158
+ healthResults: typeof getHealthResults === 'function' ? getHealthResults() : [],
159
+ modelProbes: typeof getModelProbes === 'function' ? getModelProbes() : {},
160
+ })
161
+ }
162
+
111
163
  return {
112
164
  exportConfig,
113
165
  importConfig,
114
166
  getAliases,
115
167
  setAlias,
116
168
  deleteAlias,
169
+ resolveAlias,
117
170
  }
118
171
  }
@@ -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'
@@ -5,7 +6,7 @@ import { reconcileModels, catalogPatch } from './reconcile.js'
5
6
  import { normalizeModels } from './models.js'
6
7
  import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
7
8
  import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
8
- import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
9
+ import { appendHistory, createHistoryEntry, listHistory } from './history.js'
9
10
  import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
10
11
  import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
11
12
 
@@ -23,6 +24,8 @@ import {
23
24
  adapterErrorType,
24
25
  replaceWithRetry,
25
26
  rotationRefs,
27
+ refsFor,
28
+ performHistoryRollback,
26
29
  } from './synchronizer-helpers.js'
27
30
  import { createConfigTransfer } from './synchronizer-transfer.js'
28
31
  import { createModelProber } from './synchronizer-prober.js'
@@ -49,18 +52,6 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
49
52
  return registry.select(profile, config.adapterRegistry?.[profile.provider])
50
53
  }
51
54
 
52
- function refsFor(row, settings) {
53
- const profile = profileOf(settings, row.provider)
54
- const refs = [
55
- row.apiKeyEnv,
56
- profile.credentialRef,
57
- ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
58
- ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
59
- ...rotationRefs(settings, row.provider),
60
- ]
61
- return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
62
- }
63
-
64
55
  function circuitOpen(provider, reliability) {
65
56
  if (reliability.circuitBreakerFailures <= 0) return null
66
57
  const state = circuits.get(provider)
@@ -413,6 +404,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
413
404
  result.historyError = errorMessage(error)
414
405
  }
415
406
  }
407
+ if (!dryRun || options.forceNotify) {
408
+ try {
409
+ const notifyResult = await dispatchDiffNotifications(ctx, config, results)
410
+ if (notifyResult?.dispatched) {
411
+ result.diffNotifications = notifyResult
412
+ }
413
+ } catch (err) {
414
+ if (typeof ctx?.logger?.debug === "function") {
415
+ ctx.logger.debug("[dsh-model-sync] dispatchDiffNotifications error:", err)
416
+ }
417
+ }
418
+ }
416
419
  result.notifications = notificationLedger()
417
420
  lastRun = result
418
421
  return result
@@ -449,32 +452,8 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
449
452
  }
450
453
 
451
454
  const prober = createModelProber({ health })
452
- const tryModel = prober.tryModel
453
- const batchTryModels = prober.batchTryModels
454
455
 
455
- async function rollbackHistory({ historyId, provider } = {}) {
456
- if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
457
- if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
458
- const config = getConfig() ?? {}
459
- const entry = historyEntry(config.history, historyId)
460
- if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
461
- const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
462
- if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
463
- if (!providers().some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
464
- const settings = settingsOf(ctx)
465
- const section = settings?.get?.(PIAI_NS)
466
- const descriptor = descriptorOf(settings, PIAI_NS)
467
- if (!section || !descriptor || typeof settings.replace !== 'function') {
468
- throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
469
- }
470
- const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
471
- const target = normalizeModels(provider, snapshot.before)
472
- if (JSON.stringify(current) === JSON.stringify(target)) {
473
- return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
474
- }
475
- await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, target))
476
- return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
477
- }
456
+ const rollbackHistory = (opts) => performHistoryRollback({ ctx, config: getConfig() ?? {}, providers: providers(), ...opts })
478
457
 
479
458
  async function setModelPolicy(provider, policy) {
480
459
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
@@ -551,12 +530,9 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
551
530
  saveConfig,
552
531
  listProviders: providers,
553
532
  validatePolicy,
533
+ isCircuitOpen: (p) => Boolean(circuits.get(p)?.blockedUntil > Date.now()),
534
+ getHealthResults: () => lastHealth?.results ?? [],
554
535
  })
555
- const exportConfig = transfer.exportConfig
556
- const importConfig = transfer.importConfig
557
- const getAliases = transfer.getAliases
558
- const setAlias = transfer.setAlias
559
- const deleteAlias = transfer.deleteAlias
560
536
 
561
537
  return Object.freeze({
562
538
  listProviders: providers,
@@ -569,19 +545,15 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
569
545
  setModelPolicy,
570
546
  listPolicies,
571
547
  health,
572
- tryModel,
573
- batchTryModels,
574
- exportConfig,
575
- importConfig,
576
- getAliases,
577
- setAlias,
578
- deleteAlias,
548
+ ...prober,
549
+ ...transfer,
579
550
  credentialDiagnostics,
580
551
  history,
581
552
  report: () => structuredClone(lastReport),
582
553
  notifications: notificationLedger,
583
554
  updateNotification: updateNotificationState,
584
555
  rollbackHistory,
556
+ testWebhook: async ({ url, secret }) => sendWebhookNotification(url, secret, { event: 'model-sync.test', timestamp: Date.now(), test: true }),
585
557
  status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
586
558
  })
587
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",