@goodandready/dsh-model-sync 0.3.12 → 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 +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/adapter-registry.js +20 -3
- package/lib/client.js +15 -6
- package/lib/generic-adapter.js +23 -5
- package/lib/http.js +247 -237
- package/lib/models.js +300 -282
- package/lib/policy.js +1 -1
- package/lib/reliability.js +6 -2
- package/lib/synchronizer.js +13 -2
- package/package.json +1 -1
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** 模型选单与上游大模型服务商实时同步。
|
package/lib/adapter-registry.js
CHANGED
|
@@ -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)
|
|
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
|
@@ -96,7 +96,11 @@ window.__ModuleLoader__.load({
|
|
|
96
96
|
function ModelSyncSection({ ctx, includeIntro = true }) {
|
|
97
97
|
const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
|
|
98
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)
|
|
99
|
-
const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [capabilityFilter, setCapabilityFilter] = React.useState([]); const [searchQuery, setSearchQuery] = React.useState(''); const [
|
|
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: '' })
|
|
100
104
|
const [pageSize, setPageSize] = React.useState(50); const [quickFilter, setQuickFilter] = React.useState({ context100k: false, cheap: false })
|
|
101
105
|
const scope = React.useMemo(() => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined), [ctx])
|
|
102
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' }), []))
|
|
@@ -148,11 +152,16 @@ window.__ModuleLoader__.load({
|
|
|
148
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)) }
|
|
149
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)) }
|
|
150
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; };
|
|
151
|
-
const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? [];
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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)
|
|
156
165
|
const renderNotifications = () => {
|
|
157
166
|
const entries = status?.notifications ?? []
|
|
158
167
|
if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
|
package/lib/generic-adapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|