@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 +3 -0
- package/README.ru.md +3 -0
- package/README.zh.md +3 -0
- package/lib/alias-resolver.js +216 -0
- package/lib/client.js +179 -5
- package/lib/diff-notifier.js +196 -0
- package/lib/http.js +43 -1
- package/lib/index.js +12 -2
- package/lib/models.js +2 -1
- package/lib/pricing-database.js +138 -0
- package/lib/synchronizer-helpers.js +38 -0
- package/lib/synchronizer-transfer.js +64 -11
- package/lib/synchronizer.js +22 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ Key Features
|
|
80
80
|
|
|
81
|
+
* 💰 **Built-in Reference Pricing Database**: Canonical token pricing database automatically enriches models for OpenAI, Anthropic, Groq, Mistral, xAI, and DeepSeek, enabling cost-limit filtering and `@cheap` auto-resolution even when upstream APIs omit pricing metadata.
|
|
82
|
+
* 🎯 **Dynamic Smart Virtual Aliases & Auto-Fallback**: Route requests to virtual aliases like `@fast`, `@cheap`, and `@best-code`, with automatic fallback chains when circuit breakers open or health probes fail.
|
|
83
|
+
* 🔔 **External Diff Webhooks & Event Dispatching**: Real-time webhook notifications with HMAC-SHA256 signature verification and Cordis `model-sync/diff` events when models are added, deprecated, or prices change.
|
|
81
84
|
* ⚡ **High-Performance Policy Filtering**: Fast-path evaluation in `filterModels` bypasses regular expressions and array filtering when no filtering policy is configured, guaranteeing zero-overhead discovery runs.
|
|
82
85
|
* 🔄 **One-Click In-App Updates**: Built-in update checker in Settings with automatic npm cache bypass on demand (`?fresh=1` / `Cache-Control: no-cache`), ensuring instantaneous discovery of new package releases.
|
|
83
86
|
* 🔄 **Automated Catalog Discovery**: Syncs model lists, aliases, context windows, and capability flags (`vision`, `tools`, `reasoning`, `embeddings`) across 25+ providers.
|
package/README.ru.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ Ключевые возможности
|
|
80
80
|
|
|
81
|
+
* 💰 **Встроенная база эталонных цен токенов**: Автоматическое обогащение моделей тарифами для OpenAI, Anthropic, Groq, Mistral, xAI и DeepSeek, обеспечивающее работу лимитов цен и псевдонима `@cheap` даже при отсутствии метаданных в API провайдера.
|
|
82
|
+
* 🎯 **Динамические «умные» псевдонимы и авто-фоллбэк**: Поддержка виртуальных алиасов (`@fast`, `@cheap`, `@best-code`) и цепочек резервирования с автопереключением при размыкании Circuit Breaker.
|
|
83
|
+
* 🔔 **Внешние Diff Webhooks и диспетчеризация событий**: Уведомления в реальном времени с проверкой подписи HMAC-SHA256 и событиями Cordis `model-sync/diff` при добавлении моделей, их устаревании или изменении цен.
|
|
81
84
|
* ⚡ **Высокопроизводительная фильтрация моделей**: Быстрый путь (`fast-path`) в `filterModels` мгновенно возвращает каталог при отсутствии активных правил политики, исключая лишнюю компиляцию regex и прогоны массивов.
|
|
82
85
|
* 🔄 **Обновление плагина в один клик**: Встроенный механизм проверки обновлений в Настройках со сбросом кэша npm при ручном запросе (`?fresh=1` / `Cache-Control: no-cache`), гарантирующий получение актуальной версии.
|
|
83
86
|
* 🔄 **Автоматическое обнаружение каталога**: синхронизация списков моделей, алиасов, контекстных окон и флагов возможностей (`vision`, `tools`, `reasoning`, `embeddings`) для 25+ провайдеров.
|
package/README.zh.md
CHANGED
|
@@ -78,6 +78,9 @@ graph LR
|
|
|
78
78
|
|
|
79
79
|
## ✨ 核心特性
|
|
80
80
|
|
|
81
|
+
* 💰 **内置基准价格数据库**:自动为 OpenAI、Anthropic、Groq、Mistral、xAI 和 DeepSeek 模型补充 Token 价格数据,即使服务商接口未返回价格,也可无缝支持成本上限过滤与 `@cheap` 别名解析。
|
|
82
|
+
* 🎯 **动态智能虚拟别名与故障转移**:支持 `@fast`、`@cheap`、`@best-code` 等虚拟别名及故障转移链,在熔断器打开或健康检查失败时自动切换至备用模型。
|
|
83
|
+
* 🔔 **外部差异通知 Webhook 与事件分发**:支持 HMAC-SHA256 签名验证的实时 Webhook 通知与 Cordis `model-sync/diff` 事件,可在新模型上线、模型废弃或价格变更时即时推送。
|
|
81
84
|
* ⚡ **高性能模型策略过滤**:在未配置过滤策略时,`filterModels` 采用快速路径直接返回模型目录,避免不必要的正则表达式编译和数组遍历。
|
|
82
85
|
* 🔄 **一键检查与更新**:内置设置面板更新检查,在手动刷新时通过 `?fresh=1` 或 `Cache-Control: no-cache` 绕过 npm 缓存,即时获取最新版本。
|
|
83
86
|
* 🔄 **自动化目录同步**:自动对齐 25+ 服务商的模型清单、别名、上下文窗口及能力标签(`vision`、`tools`、`reasoning`、`embeddings`)。
|
|
@@ -0,0 +1,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
|
-
|
|
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 } }, `→ ${
|
|
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
|
-
|
|
1265
|
-
React.createElement(
|
|
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 (
|
|
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()
|
|
69
|
-
model: Schema.string()
|
|
73
|
+
provider: Schema.string(),
|
|
74
|
+
model: Schema.string(),
|
|
75
|
+
targets: Schema.array(Schema.object({
|
|
76
|
+
provider: Schema.string().required(),
|
|
77
|
+
model: Schema.string().required(),
|
|
78
|
+
})),
|
|
79
|
+
strategy: Schema.string(),
|
|
70
80
|
updatedAt: Schema.number().default(0),
|
|
71
81
|
}).loose()).default({}),
|
|
72
82
|
modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
|
package/lib/models.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lookupReferencePricing } from './pricing-database.js'
|
|
1
2
|
const FIELD_ALIASES = Object.freeze({
|
|
2
3
|
contextWindow: [
|
|
3
4
|
'contextWindow', 'context_window', 'contextLength', 'context_length',
|
|
@@ -292,7 +293,7 @@ export function normalizeModel(provider, row) {
|
|
|
292
293
|
const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
|
|
293
294
|
const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
|
|
294
295
|
const capabilities = normalizeCapabilities(row)
|
|
295
|
-
const pricing = normalizePricing(row)
|
|
296
|
+
const pricing = normalizePricing(row) ?? lookupReferencePricing(effectiveProvider || provider, id)
|
|
296
297
|
const tags = normalizeTags(row)
|
|
297
298
|
const aliases = normalizeAliases(row, id)
|
|
298
299
|
const lifecycle = normalizeLifecycle(row)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function cleanTokenPrice(val) {
|
|
2
|
+
if (typeof val !== 'number' || !Number.isFinite(val)) return 0
|
|
3
|
+
return Math.round((val / 1000000) * 1e12) / 1e12
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function perMillion(inputUSD, outputUSD, extra = {}) {
|
|
7
|
+
const res = {
|
|
8
|
+
inputPerToken: cleanTokenPrice(inputUSD),
|
|
9
|
+
outputPerToken: cleanTokenPrice(outputUSD),
|
|
10
|
+
currency: 'USD',
|
|
11
|
+
source: 'reference',
|
|
12
|
+
}
|
|
13
|
+
if (extra.cacheReadUSD !== undefined) {
|
|
14
|
+
res.cacheReadPerToken = cleanTokenPrice(extra.cacheReadUSD)
|
|
15
|
+
}
|
|
16
|
+
if (extra.cacheWriteUSD !== undefined) {
|
|
17
|
+
res.cacheWritePerToken = cleanTokenPrice(extra.cacheWriteUSD)
|
|
18
|
+
}
|
|
19
|
+
return Object.freeze(res)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const REFERENCE_PRICING = Object.freeze({
|
|
23
|
+
openai: {
|
|
24
|
+
'gpt-4o': perMillion(2.5, 10),
|
|
25
|
+
'gpt-4o-mini': perMillion(0.15, 0.6),
|
|
26
|
+
'gpt-4-turbo': perMillion(10, 30),
|
|
27
|
+
'gpt-4': perMillion(30, 60),
|
|
28
|
+
'gpt-3.5-turbo': perMillion(0.5, 1.5),
|
|
29
|
+
'o1': perMillion(15, 60),
|
|
30
|
+
'o1-preview': perMillion(15, 60),
|
|
31
|
+
'o1-mini': perMillion(1.1, 4.4),
|
|
32
|
+
'o3-mini': perMillion(1.1, 4.4),
|
|
33
|
+
'chatgpt-4o-latest': perMillion(5, 15),
|
|
34
|
+
},
|
|
35
|
+
anthropic: {
|
|
36
|
+
'claude-3-7-sonnet': perMillion(3, 15),
|
|
37
|
+
'claude-3-5-sonnet': perMillion(3, 15),
|
|
38
|
+
'claude-3-5-haiku': perMillion(0.8, 4),
|
|
39
|
+
'claude-3-opus': perMillion(15, 75),
|
|
40
|
+
'claude-3-haiku': perMillion(0.25, 1.25),
|
|
41
|
+
},
|
|
42
|
+
groq: {
|
|
43
|
+
'llama-3.3-70b-versatile': perMillion(0.59, 0.79),
|
|
44
|
+
'llama-3.3-70b-specdec': perMillion(0.59, 0.99),
|
|
45
|
+
'llama-3.1-70b-versatile': perMillion(0.59, 0.79),
|
|
46
|
+
'llama-3.1-8b-instant': perMillion(0.05, 0.08),
|
|
47
|
+
'llama3-70b-8192': perMillion(0.59, 0.79),
|
|
48
|
+
'llama3-8b-8192': perMillion(0.05, 0.08),
|
|
49
|
+
'mixtral-8x7b-32768': perMillion(0.24, 0.24),
|
|
50
|
+
'gemma2-9b-it': perMillion(0.2, 0.2),
|
|
51
|
+
'deepseek-r1-distill-llama-70b': perMillion(0.75, 0.99),
|
|
52
|
+
'deepseek-r1-distill-qwen-32b': perMillion(0.59, 0.79),
|
|
53
|
+
'qwen-2.5-32b': perMillion(0.59, 0.79),
|
|
54
|
+
'qwen-2.5-coder-32b': perMillion(0.59, 0.79),
|
|
55
|
+
},
|
|
56
|
+
mistral: {
|
|
57
|
+
'mistral-large': perMillion(2, 6),
|
|
58
|
+
'mistral-small': perMillion(0.1, 0.3),
|
|
59
|
+
'codestral': perMillion(0.3, 0.9),
|
|
60
|
+
'ministral-8b': perMillion(0.1, 0.1),
|
|
61
|
+
'ministral-3b': perMillion(0.04, 0.04),
|
|
62
|
+
'pixtral-large': perMillion(2, 6),
|
|
63
|
+
'pixtral-12b': perMillion(0.15, 0.15),
|
|
64
|
+
'open-mistral-7b': perMillion(0.2, 0.2),
|
|
65
|
+
'open-mixtral-8x7b': perMillion(0.7, 0.7),
|
|
66
|
+
},
|
|
67
|
+
xai: {
|
|
68
|
+
'grok-2': perMillion(2, 10),
|
|
69
|
+
'grok-2-mini': perMillion(0.2, 1),
|
|
70
|
+
'grok-2-vision': perMillion(2, 10),
|
|
71
|
+
'grok-beta': perMillion(5, 15),
|
|
72
|
+
},
|
|
73
|
+
deepseek: {
|
|
74
|
+
'deepseek-chat': perMillion(0.14, 0.28, { cacheReadUSD: 0.014 }),
|
|
75
|
+
'deepseek-reasoner': perMillion(0.55, 2.19, { cacheReadUSD: 0.14 }),
|
|
76
|
+
'deepseek-coder': perMillion(0.14, 0.28),
|
|
77
|
+
},
|
|
78
|
+
google: {
|
|
79
|
+
'gemini-2.0-flash': perMillion(0.1, 0.4),
|
|
80
|
+
'gemini-1.5-pro': perMillion(1.25, 5),
|
|
81
|
+
'gemini-1.5-flash': perMillion(0.075, 0.3),
|
|
82
|
+
'gemini-1.5-flash-8b': perMillion(0.0375, 0.15),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function cleanId(id) {
|
|
87
|
+
if (typeof id !== 'string') return ''
|
|
88
|
+
return id
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/^models\//, '')
|
|
92
|
+
.replace(/:latest$/, '')
|
|
93
|
+
.replace(/-latest$/, '')
|
|
94
|
+
.replace(/-(202\d{5}|202\d-\d{2}-\d{2}|\d{4})$/, '')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function lookupReferencePricing(provider, modelId) {
|
|
98
|
+
if (typeof modelId !== 'string' || !modelId.trim()) return undefined
|
|
99
|
+
const provKey = String(provider || '').toLowerCase().trim()
|
|
100
|
+
const rawId = modelId.toLowerCase().trim()
|
|
101
|
+
const cleaned = cleanId(rawId)
|
|
102
|
+
|
|
103
|
+
// 1. Direct check in provider table
|
|
104
|
+
const table = REFERENCE_PRICING[provKey]
|
|
105
|
+
if (table) {
|
|
106
|
+
if (table[rawId]) return { ...table[rawId] }
|
|
107
|
+
if (table[cleaned]) return { ...table[cleaned] }
|
|
108
|
+
for (const [key, val] of Object.entries(table)) {
|
|
109
|
+
if (rawId.startsWith(key) || cleaned.startsWith(key)) return { ...val }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Vendor prefix check (e.g. "openai/gpt-4o" or "anthropic/claude-3-5-sonnet")
|
|
114
|
+
const slashIdx = rawId.indexOf('/')
|
|
115
|
+
if (slashIdx > 0) {
|
|
116
|
+
const subProv = rawId.slice(0, slashIdx)
|
|
117
|
+
const subId = rawId.slice(slashIdx + 1)
|
|
118
|
+
const subCleaned = cleanId(subId)
|
|
119
|
+
const subTable = REFERENCE_PRICING[subProv]
|
|
120
|
+
if (subTable) {
|
|
121
|
+
if (subTable[subId]) return { ...subTable[subId] }
|
|
122
|
+
if (subTable[subCleaned]) return { ...subTable[subCleaned] }
|
|
123
|
+
for (const [key, val] of Object.entries(subTable)) {
|
|
124
|
+
if (subId.startsWith(key) || subCleaned.startsWith(key)) return { ...val }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Fallback search across all tables if cleaned matches well-known key
|
|
130
|
+
for (const [p, t] of Object.entries(REFERENCE_PRICING)) {
|
|
131
|
+
if (t[cleaned]) return { ...t[cleaned] }
|
|
132
|
+
for (const [key, val] of Object.entries(t)) {
|
|
133
|
+
if (cleaned.startsWith(key)) return { ...val }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
@@ -1,4 +1,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
|
-
|
|
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 (
|
|
60
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/lib/synchronizer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dispatchDiffNotifications, sendWebhookNotification } from "./diff-notifier.js"
|
|
1
2
|
import { listApiKeyProviders } from './inventory.js'
|
|
2
3
|
import { createProviderAdapterRegistry } from './adapter-registry.js'
|
|
3
4
|
import { createCredentialResolver } from './credentials.js'
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
}
|