@goodandready/dsh-model-sync 0.4.9 → 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 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
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' } },
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 } }, `→ ${data.provider} / ${data.model}`)
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}` : ''}`))))
@@ -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().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,14 @@
1
- export function createConfigTransfer({ getConfig = () => ({}), saveConfig = async () => {}, listProviders = () => [], validatePolicy } = {}) {
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 (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)) 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 (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' })
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
- currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
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, provider, model, aliases: currentAliases }
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
  }
@@ -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
@@ -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
  }
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.0",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",