@goodandready/dsh-model-sync 0.3.11 → 0.3.13

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
@@ -107,6 +107,21 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
107
107
 
108
108
  ---
109
109
 
110
+ ## 🚀 Enhancements in v0.3.13
111
+
112
+ * ⚡ **Zero-Overhead Polling via HTTP ETag / 304 Not Modified**:
113
+ * Implemented weak ETag generation for `GET /dsh-model-sync/status`. When the Web UI polls every 15 seconds, unchanged catalogs receive an empty `304 Not Modified` response, eliminating redundant JSON serialization and network traffic.
114
+ * 🌐 **Upstream Conditional HTTP Requests**:
115
+ * Added `If-None-Match` and `If-Modified-Since` headers to upstream provider discovery. Catalogs that return `304 Not Modified` resolve instantly from memory cache without re-parsing or diff re-computation.
116
+ * 🎯 **Debounced Search & Memoized Sorting in UI**:
117
+ * Introduced 150ms input debouncing and `React.useMemo` for model filtering/sorting in the model picker, ensuring fluid search responsiveness on catalogs with hundreds of models.
118
+ * 🧠 **Expanded Capability Detection**:
119
+ * Added detection for modern reasoning models (`DeepSeek-R1`, `o1`, `o3-mini`, `thinking`) and specialized code generation models (`code`), plus support for the `code` capability filter in policy matching and UI picker.
120
+ * 🛡️ **Exponential Retry Jitter**:
121
+ * Enhanced `retryWithBackoff` with randomized jitter (`0.5 - 1.0` multiplier) to prevent thundering-herd effects on provider APIs during network hiccups or rate limits.
122
+
123
+ ---
124
+
110
125
  ## 🛠️ Enhancements & Fixes in v0.3.11
111
126
 
112
127
  * 🗂️ **Streamlined Settings UI Surface**:
package/README.ru.md CHANGED
@@ -84,6 +84,21 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
84
84
 
85
85
  ---
86
86
 
87
+ ## 🚀 Улучшения в v0.3.13
88
+
89
+ * ⚡ **ETag / 304 Not Modified для фонового опроса UI**:
90
+ * Реализована генерация слабого ETag для маршрута `GET /dsh-model-sync/status`. При 15-секундном опросе UI возвращается пустой ответ `304 Not Modified`, исключающий холостую сериализацию JSON и сетевой оверхед.
91
+ * 🌐 **Условные запросы к API провайдеров (Upstream Conditional Requests)**:
92
+ * Передача заголовков `If-None-Match` и `If-Modified-Since` в generic и declarative адаптерах. При ответе `304` от апстрим-провайдера мгновенно возвращается кэш без повторного парсинга моделей.
93
+ * 🎯 **Debounce поиска и useMemo в веб-интерфейсе**:
94
+ * Добавлен debounce (150 мс) на ввод в строку поиска и мемоизация `React.useMemo` для фильтрации и сортировки моделей в пикере, обеспечивая плавный отклик без микрофризов.
95
+ * 🧠 **Расширенное распознавание возможностей моделей**:
96
+ * Распознавание современных reasoning/thinking моделей (`DeepSeek-R1`, `o1`, `o3-mini`, CoT) и моделей для кода (`code`), поддержка тега `code` в политиках фильтрации.
97
+ * 🛡️ **Экспоненциальный джиттер при повторах**:
98
+ * Защита от thundering herd с помощью случайного коэффициента джиттера (`0.5 - 1.0`) при повторных запросах к API провайдеров.
99
+
100
+ ---
101
+
87
102
  ## 🛠️ Улучшения и исправления в v0.3.11
88
103
 
89
104
  * 🗂️ **Чистый интерфейс настроек (DSH Design Contract)**:
package/README.zh.md CHANGED
@@ -25,6 +25,21 @@
25
25
 
26
26
  ---
27
27
 
28
+ ## 🚀 v0.3.13 性能与质量优化
29
+
30
+ * ⚡ **ETag / 304 Not Modified 状态缓存**:
31
+ * 为 `GET /dsh-model-sync/status` 提供弱 ETag 支持。UI 定时 15 秒轮询时直接返回 `304 Not Modified`,避免多余 JSON 序列化与流量损耗。
32
+ * 🌐 **上游条件请求 (Upstream Conditional Requests)**:
33
+ * 向提供商 API 发送 `If-None-Match` / `If-Modified-Since`。当提供商返回 304 时直接命中缓存,无需重复解析。
34
+ * 🎯 **模型搜索防抖与 useMemo 优化**:
35
+ * 模型选择器搜索框加入 150ms debounce 与 `React.useMemo` 缓存,大幅提升大模型列表下的筛选与排序流畅度。
36
+ * 🧠 **扩展模型能力检测**:
37
+ * 支持现代推理/思考模型(DeepSeek-R1、o1/o3-mini)及代码专用模型(`code`)的能力标记与策略过滤。
38
+ * 🛡️ **带抖动的指数退避重试 (Exponential Jitter)**:
39
+ * 在 API 失败重试时加入随机抖动系数,有效防止高并发下的惊群效应。
40
+
41
+ ---
42
+
28
43
  ## ⚡ 插件概览
29
44
 
30
45
  **`dsh-model-sync`** 保持 **DeepSeek Harness** 模型选单与上游大模型服务商实时同步。
@@ -5,7 +5,7 @@ import { normalizeModels } from './models.js'
5
5
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
6
6
  const SAFE_AUTH = new Set(['bearer', 'x-api-key', 'query-key', 'none'])
7
7
  const SAFE_PARSERS = new Set(['openai', 'google'])
8
- const CAPABILITIES = new Set(['vision', 'tools', 'reasoning', 'embeddings'])
8
+ const CAPABILITIES = new Set(['vision', 'tools', 'reasoning', 'embeddings', 'code'])
9
9
  const BLOCKED_HEADERS = /authorization|api[_-]?key|token|secret|cookie/i
10
10
 
11
11
  const PROVIDER_ADAPTERS = Object.freeze({
@@ -120,7 +120,7 @@ export function normalizeAdapterConfig(config, profile = {}) {
120
120
  return { endpoint: endpoint.toString(), auth, parse, headers, modelsPath, capabilityMap, fields }
121
121
  }
122
122
 
123
- async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
123
+ async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal, etag, lastModified }) {
124
124
  const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
125
125
  const apiKey = keyRef ? await resolveCredential(keyRef) : ''
126
126
  const url = new URL(descriptor.endpoint)
@@ -128,11 +128,14 @@ async function requestSpecific(profile, descriptor, { resolveCredential, fetchIm
128
128
  if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = 'Bearer ' + apiKey
129
129
  if (descriptor.auth === 'x-api-key' && apiKey) headers['x-api-key'] = apiKey
130
130
  if (descriptor.auth === 'query-key' && apiKey) url.searchParams.set('key', apiKey)
131
+ if (etag) headers['If-None-Match'] = etag
132
+ if (lastModified) headers['If-Modified-Since'] = lastModified
131
133
  let response
132
134
  try { response = await fetchImpl(url, { headers, signal }) } catch (error) {
133
135
  if (error && typeof error === 'object') error.adapterCode = 'endpoint'
134
136
  throw error
135
137
  }
138
+ if (response.status === 304) return response
136
139
  if (!response.ok) {
137
140
  const error = catalogRequestError(response.status, response)
138
141
  error.adapterCode = [401, 403].includes(response.status) ? 'auth' : response.status === 429 ? 'rate-limit' : 'http'
@@ -145,6 +148,11 @@ async function fetchSpecific(profile, descriptor, options) {
145
148
  let payload
146
149
  try {
147
150
  const response = await requestSpecific(profile, descriptor, options)
151
+ if (response.status === 304) {
152
+ return { notModified: true, etag: response.headers?.get?.('etag') || options.etag, lastModified: response.headers?.get?.('last-modified') || options.lastModified }
153
+ }
154
+ const respEtag = response.headers?.get?.('etag') || undefined
155
+ const respLastMod = response.headers?.get?.('last-modified') || undefined
148
156
  const declared = Number(response.headers?.get?.('content-length') ?? NaN)
149
157
  if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
150
158
  const error = new Error('model catalog response is too large')
@@ -163,7 +171,16 @@ async function fetchSpecific(profile, descriptor, options) {
163
171
  throw error
164
172
  }
165
173
  const rows = parseRows(profile.provider, payload, descriptor)
166
- if (Array.isArray(rows) && rows.length > 0) return rows
174
+ if (Array.isArray(rows) && rows.length > 0) {
175
+ if (respEtag || respLastMod) {
176
+ Object.defineProperty(rows, '__httpMeta', {
177
+ value: { etag: respEtag, lastModified: respLastMod },
178
+ enumerable: false,
179
+ configurable: true,
180
+ })
181
+ }
182
+ return rows
183
+ }
167
184
  } catch (error) {
168
185
  if (descriptor.defaultModels && Array.isArray(descriptor.defaultModels) && descriptor.defaultModels.length > 0) {
169
186
  return normalizeModels(profile.provider, descriptor.defaultModels.map((id) => ({ id })))
package/lib/client.js CHANGED
@@ -28,7 +28,46 @@ window.__ModuleLoader__.load({
28
28
  '.dms-editor label{display:block;gap:5px;margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px}' +
29
29
  '.dms-editor label input,.dms-editor label textarea{width:100%}' +
30
30
  '.dms-editor input[type="checkbox"]{width:auto;accent-color:var(--dsw-alias-brand-primary)}' +
31
- '@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-editor{padding:11px 12px}}'
31
+ '.dms-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px;box-sizing:border-box}' + '.dms-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;line-height:1.4}' + '.dms-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-6px;line-height:1.4}' + '.dms-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}' + '.dms-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}' + '.dms-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' + '.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' + '.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' + '.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' + '.dms-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 10px;font-size:13px;box-sizing:border-box}' + '.dms-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' + '.dms-btn-primary{background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;border-color:transparent!important;font-weight:600}' + '.dms-btn-primary:hover:not(:disabled){opacity:0.88}' + '.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:rgba(239,68,68,0.3)!important}' + '.dms-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12)!important;border-color:rgba(239,68,68,0.5)!important}' + '.dms-alert{padding:10px 14px;border-radius:8px;font-size:13px}' + '.dms-alert-ok{background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary)}' + '.dms-alert-bad{background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary)}' + '.dms-probe-result{padding:2px 6px;border-radius:4px;font-family:monospace;font-size:11px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px}' + '@media (max-width:540px){.dms-provider{grid-template-columns:1fr!important}.dms-provider .dms-btn{justify-self:start}.dms-toolbar>div:first-child{align-items:stretch;flex-direction:column}.dms-toolbar>div:first-child select{width:100%}.dms-toolbar,.dms-section,.dms-section-card,.dms-editor{padding:11px 12px}}'
32
+
33
+ function createErrorBoundary() {
34
+ if (!React || typeof React.Component !== 'function') {
35
+ return function NoopBoundary(props) { return props?.children || null }
36
+ }
37
+ return class ErrorBoundary extends React.Component {
38
+ constructor(props) {
39
+ super(props)
40
+ this.state = { hasError: false, error: null }
41
+ }
42
+ static getDerivedStateFromError(error) {
43
+ return { hasError: true, error }
44
+ }
45
+ componentDidCatch(error, errorInfo) {
46
+ console.error('[dsh-model-sync] React Error:', error, errorInfo)
47
+ }
48
+ render() {
49
+ if (this.state.hasError) {
50
+ return React.createElement(
51
+ 'div',
52
+ {
53
+ className: 'dms-alert dms-alert-bad',
54
+ style: { margin: '12px 0', padding: '14px', borderRadius: '8px', border: '1px solid var(--dsw-alias-state-error-primary)' },
55
+ },
56
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Model Sync UI Error:'),
57
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
58
+ React.createElement('button', {
59
+ type: 'button',
60
+ className: 'dms-btn',
61
+ style: { marginTop: '8px' },
62
+ onClick: () => this.setState({ hasError: false, error: null })
63
+ }, 'Retry')
64
+ )
65
+ }
66
+ return this.props.children
67
+ }
68
+ }
69
+ }
70
+ const ErrorBoundary = createErrorBoundary()
32
71
 
33
72
  function ensureStyles() {
34
73
  if (typeof document === 'undefined' || !document.head) return
@@ -57,7 +96,11 @@ window.__ModuleLoader__.load({
57
96
  function ModelSyncSection({ ctx, includeIntro = true }) {
58
97
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
59
98
  const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [removeMissing, setRemoveMissing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [credentialStatus, setCredentialStatus] = React.useState(null)
60
- const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [capabilityFilter, setCapabilityFilter] = React.useState([]); const [searchQuery, setSearchQuery] = React.useState(''); const [sortBy, setSortBy] = React.useState('name'); const [preview, setPreview] = React.useState(null); const [policyProvider, setPolicyProvider] = React.useState(''); const [policyDraft, setPolicyDraft] = React.useState({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' })
99
+ const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [capabilityFilter, setCapabilityFilter] = React.useState([]); const [searchQuery, setSearchQuery] = React.useState(''); const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState(''); const [sortBy, setSortBy] = React.useState('name');
100
+ React.useEffect(() => {
101
+ const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery) }, 150);
102
+ return () => clearTimeout(timer);
103
+ }, [searchQuery]); const [preview, setPreview] = React.useState(null); const [policyProvider, setPolicyProvider] = React.useState(''); const [policyDraft, setPolicyDraft] = React.useState({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' })
61
104
  const [pageSize, setPageSize] = React.useState(50); const [quickFilter, setQuickFilter] = React.useState({ context100k: false, cheap: false })
62
105
  const scope = React.useMemo(() => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined), [ctx])
63
106
  const snapshot = React.useSyncExternalStore(React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]), React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]), React.useCallback(() => ({ status: 'loading' }), []))
@@ -109,11 +152,16 @@ window.__ModuleLoader__.load({
109
152
  const saveSelection = () => { const row = rows.find((item) => item.provider === selectionProvider); const available = row?.availableModels ?? row?.models ?? []; if (available.length > 0 && selectionDraft.length === 0) { setError(t.selectAtLeastOne); return } const models = selectionDraft.length === available.length ? [] : selectionDraft; setBusy(true); setError(''); fetch(`${API}/selection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: selectionProvider, models }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setStatus(body); closeSelection() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
110
153
  const rollback = (entry, targetProvider) => { if (!window.confirm(t.rollbackConfirm)) return; setBusy(true); setError(''); fetch(`${API}/history/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ historyId: entry.id, provider: targetProvider }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
111
154
  const isNewModel = (model, row) => { const added = status?.lastRun?.results?.find(r=>r.provider===row.provider)?.diff?.added||[]; const isAdded = added.some(m=>m.id===model.id); if(!isAdded) return false; const finishedAt = status?.lastRun?.finishedAt; if(!finishedAt) return false; return (Date.now()-new Date(finishedAt).getTime()) < 7*24*60*60*1000; };
112
- const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? []; const filteredChoicesBase = filterModelsByCapabilities(choices, capabilityFilter);
113
- const tokens = searchQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
114
- const searchedChoices = tokens.length ? filteredChoicesBase.filter((m) => { const haystack = (m.id + ' ' + (m.name||'') + ' ' + (m.aliases||[]).join(' ') + ' ' + (m.tags||[]).join(' ') + ' ' + (m.description||'')).toLowerCase(); return tokens.every((tok) => haystack.includes(tok)); }) : filteredChoicesBase;
115
- const quickFilteredChoices = searchedChoices.filter((m) => { if (quickFilter.context100k && (!m.contextWindow || m.contextWindow < 100000)) return false; if (quickFilter.cheap && (!m.pricing?.inputPerToken || (m.pricing.inputPerToken * 1000000) > 1.0)) return false; return true; });
116
- const filteredChoices = [...quickFilteredChoices].sort((a,b) => { if (sortBy==='price') { const pa=a.pricing?.inputPerToken??Infinity; const pb=b.pricing?.inputPerToken??Infinity; return pa-pb; } if (sortBy==='context') return (b.contextWindow??0)-(a.contextWindow??0); if (sortBy==='date') return (b.firstSeenAt??0)-(a.firstSeenAt??0); return (a.name || a.id || '').localeCompare(b.name || b.id || ''); }); const maxContext = filteredChoices.reduce((max, m) => Math.max(max, m.contextWindow || 0), 0); const minPrice = filteredChoices.reduce((min, m) => m.pricing?.inputPerToken !== undefined ? Math.min(min, m.pricing.inputPerToken) : min, Infinity); const availableCapabilities = [...new Set(choices.flatMap((model) => Object.entries(model?.capabilities ?? {}).filter(([, enabled]) => enabled === true).map(([key]) => key)))].sort(); const visibleChoices = filteredChoices.slice(0, pageSize); const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' }, label)
155
+ const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? [];
156
+ const filteredChoices = React.useMemo(() => {
157
+ const filteredChoicesBase = filterModelsByCapabilities(choices, capabilityFilter);
158
+ const tokens = debouncedSearchQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
159
+ const searchedChoices = tokens.length ? filteredChoicesBase.filter((m) => { const haystack = (m.id + ' ' + (m.name||'') + ' ' + (m.aliases||[]).join(' ') + ' ' + (m.tags||[]).join(' ') + ' ' + (m.description||'')).toLowerCase(); return tokens.every((tok) => haystack.includes(tok)); }) : filteredChoicesBase;
160
+ const quickFilteredChoices = searchedChoices.filter((m) => { if (quickFilter.context100k && (!m.contextWindow || m.contextWindow < 100000)) return false; if (quickFilter.cheap && (!m.pricing?.inputPerToken || (m.pricing.inputPerToken * 1000000) > 1.0)) return false; return true; });
161
+ return [...quickFilteredChoices].sort((a,b) => { if (sortBy==='price') { const pa=a.pricing?.inputPerToken??Infinity; const pb=b.pricing?.inputPerToken??Infinity; return pa-pb; } if (sortBy==='context') return (b.contextWindow??0)-(a.contextWindow??0); if (sortBy==='date') return (b.firstSeenAt??0)-(a.firstSeenAt??0); return (a.name || a.id || '').localeCompare(b.name || b.id || ''); });
162
+ }, [choices, capabilityFilter, debouncedSearchQuery, quickFilter, sortBy]);
163
+ const maxContext = React.useMemo(() => filteredChoices.reduce((max, m) => Math.max(max, m.contextWindow || 0), 0), [filteredChoices]);
164
+ const minPrice = React.useMemo(() => filteredChoices.reduce((min, m) => m.pricing?.inputPerToken !== undefined ? Math.min(min, m.pricing.inputPerToken) : min, Infinity), [filteredChoices]); const availableCapabilities = [...new Set(choices.flatMap((model) => Object.entries(model?.capabilities ?? {}).filter(([, enabled]) => enabled === true).map(([key]) => key)))].sort(); const visibleChoices = filteredChoices.slice(0, pageSize); const button = (label, onClick, variant) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' + (variant ? ' dms-btn-' + variant : '') }, label)
117
165
  const renderNotifications = () => {
118
166
  const entries = status?.notifications ?? []
119
167
  if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
@@ -132,7 +180,7 @@ window.__ModuleLoader__.load({
132
180
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } }, React.createElement('input', { type: 'checkbox', checked: !!schedDraft.scheduleEnabled, onChange: (e) => setSchedDraft((d) => ({ ...d, scheduleEnabled: e.target.checked })), disabled: busy }), t.scheduleEnabled),
133
181
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12 } }, React.createElement('span', { style: { minWidth: 140 } }, t.intervalMinutes + ':'), React.createElement('input', { type: 'number', min: 1, max: 1440, value: schedDraft.intervalMinutes, onChange: (e) => setSchedDraft((d) => ({ ...d, intervalMinutes: Math.max(1, Number(e.target.value) || 1) })), disabled: busy, style: { width: 90, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'inherit' } })),
134
182
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } }, React.createElement('input', { type: 'checkbox', checked: !!schedDraft.autoApply, onChange: (e) => setSchedDraft((d) => ({ ...d, autoApply: e.target.checked })), disabled: busy }), t.autoApplySchedule),
135
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } }, button(t.saveScheduler, saveScheduler), schedMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, schedMsg) : null, schedErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, schedErr) : null)
183
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } }, button(t.saveScheduler, saveScheduler, 'primary'), schedMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, schedMsg) : null, schedErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, schedErr) : null)
136
184
  ) : null
137
185
  )
138
186
  }
@@ -147,15 +195,15 @@ window.__ModuleLoader__.load({
147
195
  const details = `+ ${names(item.diff?.added)}; - ${names(item.diff?.removed)}; ⇄ ${(item.diff?.renamed ?? []).map((pair) => `${pair.before?.id} → ${pair.after?.id}`).join(', ')}; ~ ${(item.diff?.changed ?? []).map((pair) => pair.after?.id).join(', ')}`
148
196
  return React.createElement('div', { key: item.provider, style: { margin: '6px 0 6px 12px' } },
149
197
  React.createElement('span', null, `${item.provider}: ${item.status} · +${counts.added ?? 0} ${t.diffAdded}, -${counts.removed ?? 0} ${t.diffRemoved}, ${counts.renamed ?? 0} ${t.diffRenamed}, ${counts.changed ?? 0} ${t.diffChanged}`),
150
- item.hasRollback ? button(t.rollback, () => rollback(entry, item.provider)) : null,
198
+ item.hasRollback ? button(t.rollback, () => rollback(entry, item.provider), 'danger') : null,
151
199
  React.createElement('small', { style: { display: 'block', marginTop: 4 } }, details))
152
200
  })))
153
201
  }
154
202
  return React.createElement('div', { className: 'dms-wrap' },
155
203
  includeIntro ? React.createElement('p', null, t.desc) : null,
156
204
  React.createElement('div', { className: 'dms-toolbar' }, React.createElement('select', { value: provider, onChange: (e) => setProvider(e.target.value) }, React.createElement('option', { value: '' }, t.all), rows.map((row) => React.createElement('option', { key: row.provider, value: row.provider }, row.provider))), React.createElement('label', { style: { marginLeft: 12 } }, React.createElement('input', { type: 'checkbox', checked: dryRun, onChange: (e) => setDryRun(e.target.checked) }), ` ${t.dryRun}`), React.createElement('label', { style: { marginLeft: 12 } }, React.createElement('input', { type: 'checkbox', checked: removeMissing, onChange: (e) => setRemoveMissing(e.target.checked) }), ` ${t.removeMissing}`)),
157
- React.createElement('div', { className: 'dms-actions' }, button(t.refresh, load), button(t.refreshAll, () => execute(undefined)), button(dryRun ? t.run : t.apply, () => execute(provider || undefined)), button(t.health, () => checkHealth(provider || undefined)), button(t.checkCredentials, () => checkCredentials(provider || undefined))),
158
- status?.lastRun ? React.createElement('small', null, t.lastRun + ': ' + new Date(status.lastRun.finishedAt).toLocaleString()) : null, preview ? React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.previewTitle), (preview.results ?? []).filter((row) => row.status === 'ok').map((row) => React.createElement('div', { key: row.provider, style: { marginTop: 8 } }, React.createElement('strong', null, row.provider), React.createElement('div', null, t.previewAdded + ': ' + ((row.diff?.added ?? []).map((model) => model.name ?? model.id).join(', ') || '—')), React.createElement('div', null, t.previewRemoved + ': ' + ((row.diff?.removed ?? []).map((model) => model.name ?? model.id).join(', ') || '—')), React.createElement('div', null, t.previewChanged + ': ' + ((row.diff?.changed ?? []).map((change) => change.after?.name ?? change.after?.id).join(', ') || '—')))), (preview.results ?? []).every((row) => row.status !== 'ok' || !row.diff?.hasChanges) ? React.createElement('small', { style: { display: 'block', marginTop: 8 } }, t.previewNone) : null, React.createElement('div', { className: 'dms-actions', style: { marginTop: 8 } }, button(t.previewApply, () => execute(preview.provider || undefined, false)), button(t.previewDismiss, () => setPreview(null)))) : null, status?.lastReport ? React.createElement('div', { style: { marginTop: 8 } }, React.createElement('strong', null, t.report), React.createElement('small', { style: { display: 'block' } }, (t[status.lastReport.outcome] ?? status.lastReport.outcome) + ' · ' + (status.lastReport.counts?.succeeded ?? 0) + '/' + (status.lastReport.counts?.providers ?? 0) + ' providers · ' + (status.lastReport.counts?.models?.added ?? 0) + ' ' + t.diffAdded)) : null, status?.scheduler ? React.createElement('small', null, `${t.scheduler}: ${status.scheduler.active ? t.schedulerOn : t.schedulerOff}${status.scheduler.nextRunAt ? ` · ${t.nextRun}: ${new Date(status.scheduler.nextRunAt).toLocaleString()}` : ''}`) : null, status?.running || busy ? React.createElement('small', null, t.running) : null, error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null, status === null ? React.createElement('small', null, t.loading) : null, rows.length === 0 && status ? React.createElement('small', null, t.noProviders) : null,
205
+ React.createElement('div', { className: 'dms-actions' }, button(t.refresh, load), button(t.refreshAll, () => execute(undefined)), button(dryRun ? t.run : t.apply, () => execute(provider || undefined), dryRun ? undefined : 'primary'), button(t.health, () => checkHealth(provider || undefined)), button(t.checkCredentials, () => checkCredentials(provider || undefined))),
206
+ status?.lastRun ? React.createElement('small', null, t.lastRun + ': ' + new Date(status.lastRun.finishedAt).toLocaleString()) : null, preview ? React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.previewTitle), (preview.results ?? []).filter((row) => row.status === 'ok').map((row) => React.createElement('div', { key: row.provider, style: { marginTop: 8 } }, React.createElement('strong', null, row.provider), React.createElement('div', null, t.previewAdded + ': ' + ((row.diff?.added ?? []).map((model) => model.name ?? model.id).join(', ') || '—')), React.createElement('div', null, t.previewRemoved + ': ' + ((row.diff?.removed ?? []).map((model) => model.name ?? model.id).join(', ') || '—')), React.createElement('div', null, t.previewChanged + ': ' + ((row.diff?.changed ?? []).map((change) => change.after?.name ?? change.after?.id).join(', ') || '—')))), (preview.results ?? []).every((row) => row.status !== 'ok' || !row.diff?.hasChanges) ? React.createElement('small', { style: { display: 'block', marginTop: 8 } }, t.previewNone) : null, React.createElement('div', { className: 'dms-actions', style: { marginTop: 8 } }, button(t.previewApply, () => execute(preview.provider || undefined, false), 'primary'), button(t.previewDismiss, () => setPreview(null)))) : null, status?.lastReport ? React.createElement('div', { style: { marginTop: 8 } }, React.createElement('strong', null, t.report), React.createElement('small', { style: { display: 'block' } }, (t[status.lastReport.outcome] ?? status.lastReport.outcome) + ' · ' + (status.lastReport.counts?.succeeded ?? 0) + '/' + (status.lastReport.counts?.providers ?? 0) + ' providers · ' + (status.lastReport.counts?.models?.added ?? 0) + ' ' + t.diffAdded)) : null, status?.scheduler ? React.createElement('small', null, `${t.scheduler}: ${status.scheduler.active ? t.schedulerOn : t.schedulerOff}${status.scheduler.nextRunAt ? ` · ${t.nextRun}: ${new Date(status.scheduler.nextRunAt).toLocaleString()}` : ''}`) : null, status?.running || busy ? React.createElement('small', null, t.running) : null, error ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null, status === null ? React.createElement('small', null, t.loading) : null, rows.length === 0 && status ? React.createElement('small', null, t.noProviders) : null,
159
207
  rows.map((row) => {
160
208
  const health = status?.lastHealth?.results?.find((item) => item.provider === row.provider)
161
209
  const healthText = health ? ` · ${health.status === 'ok' ? t.healthOk : health.status === 'dormant' ? t.healthDormant : t.healthError}` : ''
@@ -169,15 +217,28 @@ window.__ModuleLoader__.load({
169
217
  const latencyText = healthEntry?.latencyMs ? ` · ${healthEntry.latencyMs}ms` : ''
170
218
  const isOffline = latestRun?.status !== 'ok' && modelCount > 0
171
219
  const offlineText = isOffline ? ' · ' + t.cacheHoursAgo.replace('%d', Math.max(0, Math.round((Date.now() - (status?.lastRun?.finishedAt ? new Date(status.lastRun.finishedAt).getTime() : Date.now()))/3600000))) : ''
172
- const providerCard = React.createElement('div', { className: 'dms-provider' }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${modelSummary}${lifecycleText}${healthText}${latencyText}${offlineText}`), button(t.selectModels, () => openSelection(row)), button(t.policy, () => openPolicy(row)))
173
- const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: t.searchPlaceholder, value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, t.sortName), React.createElement('option', {value:'price'}, t.sortPrice), React.createElement('option', {value:'context'}, t.sortContext), React.createElement('option', {value:'date'}, t.sortDate))), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.context100k ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, context100k: !f.context100k })) }, (quickFilter.context100k ? '✓ ' : '') + t.filterContext100k), React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.cheap ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, cheap: !f.cheap })) }, (quickFilter.cheap ? '✓ ' : '') + t.filterCheap)), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, button(t.selectAllFiltered, () => { const ids = new Set(selectionDraft); for (const m of filteredChoices) ids.add(m.id); setSelectionDraft([...ids]); }), button(t.deselectAllFiltered, () => { const filterSet = new Set(filteredChoices.map((m) => m.id)); setSelectionDraft(selectionDraft.filter((id) => !filterSet.has(id))); }), button(t.invertFiltered, () => { const current = new Set(selectionDraft); for (const m of filteredChoices) { if (current.has(m.id)) current.delete(m.id); else current.add(m.id); } setSelectionDraft([...current]); })), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount), visibleChoices.map((model) => { const deprecationDays = (() => { const d = model.lifecycle?.deprecationDate; if (!d) return null; const diff = new Date(d).getTime() - Date.now(); return diff > 0 ? Math.ceil(diff / (1000 * 60 * 60 * 24)) : 0; })(); return React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model,row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`, isNewModel(model,row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null, deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null, (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, '★') : null, (model.pricing && minPrice !== Infinity && model.pricing.inputPerToken === minPrice) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null, model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null, model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken*1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken!==undefined ? ` / cache $${(model.pricing.cacheReadPerToken*1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken!==undefined ? ` / out $${(model.pricing.outputPerToken*1000000).toFixed(2)}` : '')) : null, model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0,120)) : null, row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL||'') + '/models/'+encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null, React.createElement('button', { type: 'button', onClick: (e)=>{e.preventDefault(); doTry(row.provider, model.id);}, style: { marginLeft: 8, fontSize: 11, padding: '1px 6px', borderRadius: 4 } }, (tryState[row.provider+':'+model.id] && tryState[row.provider+':'+model.id].loading) ? '…' : '▶'), tryState[row.provider+':'+model.id] && !tryState[row.provider+':'+model.id].loading ? React.createElement('small', { style: { marginLeft: 4, color: tryState[row.provider+':'+model.id].ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontSize: 11 } }, tryState[row.provider+':'+model.id].ok ? `${tryState[row.provider+':'+model.id].latency}ms` : (tryState[row.provider+':'+model.id].error||tryState[row.provider+':'+model.id].status)) : null); }), filteredChoices.length > pageSize ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' } }, button(t.showMore.replace('%d', Math.min(50, filteredChoices.length - pageSize)), () => setPageSize((s) => s + 50)), React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.showingCount.replace('%d', visibleChoices.length).replace('%d', filteredChoices.length))) : null)), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null
174
- const policyEditor = policyProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.policy}: ${policyProvider}`), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyInclude, React.createElement('textarea', { rows: 3, value: policyDraft.include, onChange: (e) => setPolicyDraft((current) => ({ ...current, include: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyExclude, React.createElement('textarea', { rows: 3, value: policyDraft.exclude, onChange: (e) => setPolicyDraft((current) => ({ ...current, exclude: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyRequire, React.createElement('input', { value: policyDraft.requireCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, requireCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyDeny, React.createElement('input', { value: policyDraft.denyCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, denyCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('small', null, t.policyNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.savePolicy, savePolicy), button(t.cancel, closePolicy))) : null
220
+ const statusBadgeClass = row.configured ? 'dms-badge dms-badge-ok' : 'dms-badge dms-badge-warn'
221
+ const healthBadgeClass = health?.status === 'ok' ? 'dms-badge dms-badge-ok' : health?.status === 'dormant' ? 'dms-badge' : 'dms-badge dms-badge-bad'
222
+ const providerCard = React.createElement('div', { className: 'dms-provider' },
223
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
224
+ React.createElement('strong', { style: { fontSize: 13, marginRight: 2 } }, row.provider),
225
+ React.createElement('span', { className: statusBadgeClass }, row.configured ? t.ok : t.dormant),
226
+ React.createElement('span', { className: 'dms-badge' }, modelSummary),
227
+ health ? React.createElement('span', { className: healthBadgeClass }, `${health.status === 'ok' ? t.healthOk : health.status === 'dormant' ? t.healthDormant : t.healthError}${latencyText}`) : null,
228
+ lifecycleText ? React.createElement('span', { className: 'dms-badge dms-badge-warn' }, lifecycleText.replace(/^ · /, '')) : null,
229
+ offlineText ? React.createElement('span', { className: 'dms-badge dms-badge-warn' }, offlineText.replace(/^ · /, '')) : null
230
+ ),
231
+ button(t.selectModels, () => openSelection(row)),
232
+ button(t.policy, () => openPolicy(row))
233
+ )
234
+ const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: t.searchPlaceholder, value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, t.sortName), React.createElement('option', {value:'price'}, t.sortPrice), React.createElement('option', {value:'context'}, t.sortContext), React.createElement('option', {value:'date'}, t.sortDate))), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.context100k ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, context100k: !f.context100k })) }, (quickFilter.context100k ? '✓ ' : '') + t.filterContext100k), React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.cheap ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, cheap: !f.cheap })) }, (quickFilter.cheap ? '✓ ' : '') + t.filterCheap)), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, button(t.selectAllFiltered, () => { const ids = new Set(selectionDraft); for (const m of filteredChoices) ids.add(m.id); setSelectionDraft([...ids]); }), button(t.deselectAllFiltered, () => { const filterSet = new Set(filteredChoices.map((m) => m.id)); setSelectionDraft(selectionDraft.filter((id) => !filterSet.has(id))); }), button(t.invertFiltered, () => { const current = new Set(selectionDraft); for (const m of filteredChoices) { if (current.has(m.id)) current.delete(m.id); else current.add(m.id); } setSelectionDraft([...current]); })), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount), visibleChoices.map((model) => { const deprecationDays = (() => { const d = model.lifecycle?.deprecationDate; if (!d) return null; const diff = new Date(d).getTime() - Date.now(); return diff > 0 ? Math.ceil(diff / (1000 * 60 * 60 * 24)) : 0; })(); return React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model,row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`, isNewModel(model,row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null, deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null, (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, '★') : null, (model.pricing && minPrice !== Infinity && model.pricing.inputPerToken === minPrice) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null, model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null, model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken*1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken!==undefined ? ` / cache $${(model.pricing.cacheReadPerToken*1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken!==undefined ? ` / out $${(model.pricing.outputPerToken*1000000).toFixed(2)}` : '')) : null, model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0,120)) : null, row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL||'') + '/models/'+encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null, React.createElement('button', { type: 'button', onClick: (e)=>{e.preventDefault(); doTry(row.provider, model.id);}, style: { marginLeft: 8, fontSize: 11, padding: '1px 6px', borderRadius: 4 } }, (tryState[row.provider+':'+model.id] && tryState[row.provider+':'+model.id].loading) ? '…' : '▶'), tryState[row.provider+':'+model.id] && !tryState[row.provider+':'+model.id].loading ? React.createElement('small', { style: { marginLeft: 4, color: tryState[row.provider+':'+model.id].ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontSize: 11 } }, tryState[row.provider+':'+model.id].ok ? `${tryState[row.provider+':'+model.id].latency}ms` : (tryState[row.provider+':'+model.id].error||tryState[row.provider+':'+model.id].status)) : null); }), filteredChoices.length > pageSize ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' } }, button(t.showMore.replace('%d', Math.min(50, filteredChoices.length - pageSize)), () => setPageSize((s) => s + 50)), React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.showingCount.replace('%d', visibleChoices.length).replace('%d', filteredChoices.length))) : null)), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection, 'primary'), button(t.cancel, closeSelection))) : null
235
+ const policyEditor = policyProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.policy}: ${policyProvider}`), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyInclude, React.createElement('textarea', { rows: 3, value: policyDraft.include, onChange: (e) => setPolicyDraft((current) => ({ ...current, include: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyExclude, React.createElement('textarea', { rows: 3, value: policyDraft.exclude, onChange: (e) => setPolicyDraft((current) => ({ ...current, exclude: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyRequire, React.createElement('input', { value: policyDraft.requireCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, requireCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyDeny, React.createElement('input', { value: policyDraft.denyCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, denyCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('small', null, t.policyNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.savePolicy, savePolicy, 'primary'), button(t.cancel, closePolicy))) : null
175
236
  return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, selectionEditor, policyEditor)
176
237
  }),
177
- React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.notifications), renderNotifications()),
178
- React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.credentials), 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}` : ''}`))))),
179
- React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.schedulerSettings), renderScheduler()),
180
- React.createElement('div', { className: 'dms-section' }, React.createElement('strong', null, t.history), renderHistory()),
238
+ React.createElement('div', { className: 'dms-section-card dms-section' }, React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.notifications)), renderNotifications()),
239
+ React.createElement('div', { className: 'dms-section-card dms-section' }, React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.credentials)), 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}` : ''}`))))),
240
+ React.createElement('div', { className: 'dms-section-card dms-section' }, React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.schedulerSettings)), renderScheduler()),
241
+ React.createElement('div', { className: 'dms-section-card dms-section' }, React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.history)), renderHistory()),
181
242
  )
182
243
  }
183
244
  function ModelSyncCard({ ctx }) {
@@ -190,7 +251,7 @@ window.__ModuleLoader__.load({
190
251
  React.createElement('span', { className: 'dms-card-head-text' }, React.createElement('span', { className: 'dms-card-name' }, t.title), React.createElement('span', { className: 'dms-card-description' }, t.desc)),
191
252
  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,
192
253
  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' }, 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' }))),
193
- open ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ModelSyncSection, { ctx, includeIntro: false })) : null)
254
+ open ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ErrorBoundary, null, React.createElement(ModelSyncSection, { ctx, includeIntro: false }))) : null)
194
255
  }
195
256
  function apply(ctx) {
196
257
  ctx.effect(() => {
@@ -198,7 +259,7 @@ window.__ModuleLoader__.load({
198
259
  return () => {}
199
260
  }, 'dsh-model-sync: dictionaries')
200
261
  try {
201
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ModelSyncCard, { ...props, ctx })))
262
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) }, (props) => React.createElement(ErrorBoundary, null, React.createElement(ModelSyncCard, { ...props, ctx }))))
202
263
  } catch {}
203
264
  }
204
265
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], filterModelsByCapabilities }; return module.exports
@@ -31,16 +31,19 @@ export function canUseGenericAdapter(profile) {
31
31
  return Boolean(profile?.baseURL) && SUPPORTED_APIS.has(profile?.api || 'openai-completions')
32
32
  }
33
33
 
34
- function requestHeaders(apiKey) {
34
+ function requestHeaders(apiKey, etag, lastModified) {
35
35
  const headers = { Accept: 'application/json' }
36
36
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`
37
+ if (etag) headers['If-None-Match'] = etag
38
+ if (lastModified) headers['If-Modified-Since'] = lastModified
37
39
  return headers
38
40
  }
39
41
 
40
- async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal }) {
42
+ async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal, etag, lastModified }) {
41
43
  const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
42
44
  const apiKey = keyRef ? await resolveCredential(keyRef) : ''
43
- const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey), signal })
45
+ const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey, etag, lastModified), signal })
46
+ if (response.status === 304) return response
44
47
  if (!response.ok) throw catalogRequestError(response.status, response)
45
48
  return response
46
49
  }
@@ -49,18 +52,33 @@ export async function discoverOpenAIModels(profile, {
49
52
  resolveCredential,
50
53
  fetchImpl = fetch,
51
54
  signal,
55
+ etag,
56
+ lastModified,
52
57
  } = {}) {
53
58
  if (!canUseGenericAdapter(profile)) {
54
59
  throw new Error('generic discovery requires an OpenAI-compatible baseURL and protocol')
55
60
  }
56
- const response = await resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal })
61
+ const response = await resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal, etag, lastModified })
62
+ if (response.status === 304) {
63
+ return { notModified: true, etag: response.headers?.get?.('etag') || etag, lastModified: response.headers?.get?.('last-modified') || lastModified }
64
+ }
65
+ const respEtag = response.headers?.get?.('etag') || undefined
66
+ const respLastMod = response.headers?.get?.('last-modified') || undefined
57
67
  let payload
58
68
  try {
59
69
  payload = JSON.parse(await readBody(response))
60
70
  } catch {
61
71
  throw new Error('model catalog response is not valid JSON')
62
72
  }
63
- return normalizeModels(profile.provider ?? '', rowsFrom(payload))
73
+ const models = normalizeModels(profile.provider ?? '', rowsFrom(payload))
74
+ if (respEtag || respLastMod) {
75
+ Object.defineProperty(models, '__httpMeta', {
76
+ value: { etag: respEtag, lastModified: respLastMod },
77
+ enumerable: false,
78
+ configurable: true,
79
+ })
80
+ }
81
+ return models
64
82
  }
65
83
 
66
84