@goodandready/dsh-model-sync 0.3.5 → 0.3.7
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 +20 -0
- package/README.ru.md +20 -0
- package/lib/adapter-registry.js +8 -1
- package/lib/client.js +6 -6
- package/lib/generic-adapter.js +2 -1
- package/lib/http.js +13 -2
- package/lib/index.js +1 -1
- package/lib/inventory.js +3 -2
- package/lib/lifecycle.js +1 -1
- package/lib/models.js +9 -1
- package/lib/reconcile.js +2 -2
- package/lib/scheduler.js +2 -2
- package/lib/synchronizer.js +8 -2
- package/package.json +3 -6
package/README.md
CHANGED
|
@@ -107,6 +107,26 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
+
## 🛠️ Enhancements & Fixes in v0.3.6
|
|
111
|
+
|
|
112
|
+
* 🔑 **Universal Credential Resolution (`credentialRef` & `apiKeyRef`)**:
|
|
113
|
+
* Added full support for modern DSH `credentialRef` and `apiKeyRef` references across inventory detection, provider adapters, and OpenAI-compatible generic routes.
|
|
114
|
+
* Providers configured via DSH Credentials Service are now accurately discovered and authenticated without requiring legacy `apiKeyEnv`.
|
|
115
|
+
* ♻️ **Lifecycle Cleanup for Deprecated Models**:
|
|
116
|
+
* Fixed an issue where models previously marked as `deprecated` would remain indefinitely in settings when removed by the upstream provider. When `removeMissing: true` is enabled, missing deprecated models now cleanly transition to `removed` status once the grace period expires.
|
|
117
|
+
* ⏰ **Accurate Scheduler Inactive Status**:
|
|
118
|
+
* Corrected `scheduler.status()` to return `nextRunAt: null` when background synchronization is disabled, avoiding misleading run times in the Web UI.
|
|
119
|
+
* 🛡️ **Web UI Resilience & Big Catalog Scalability**:
|
|
120
|
+
* Eliminated potential UI crashes during model sorting by safely handling missing model display names.
|
|
121
|
+
* Replaced spread operations on model arrays with iterative aggregations, preventing call stack overflow (`RangeError`) on massive catalogs (10,000+ models).
|
|
122
|
+
* Surfaced detailed probe error messages in the UI when testing individual models via the `▶` button.
|
|
123
|
+
* Localized cache expiration and offline status strings in Russian and English.
|
|
124
|
+
* ⚡ **Deterministic Model Diffing & HTTP Stream Safety**:
|
|
125
|
+
* Implemented stable key-sorted serialization in `diffModels` and `sameModel` to eliminate false-positive diffs caused by non-deterministic JSON key order.
|
|
126
|
+
* Added early stream destruction on oversized HTTP request bodies to prevent memory bloat.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
110
130
|
## 🚀 Enhancements in v0.3.5
|
|
111
131
|
|
|
112
132
|
* ⏱️ **Adaptive Rate Limit & Retry-After Handling**:
|
package/README.ru.md
CHANGED
|
@@ -84,6 +84,26 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
|
84
84
|
|
|
85
85
|
---
|
|
86
86
|
|
|
87
|
+
## 🛠️ Улучшения и исправления в v0.3.6
|
|
88
|
+
|
|
89
|
+
* 🔑 **Универсальное разрешение учетных данных (`credentialRef` и `apiKeyRef`)**:
|
|
90
|
+
* Добавлена полная поддержка ссылок `credentialRef` и `apiKeyRef` в модулях инвентаря, реестре адаптеров и generic-маршрутах OpenAI-совместимых провайдеров.
|
|
91
|
+
* Провайдеры, настроенные через DSH Credentials Service, теперь корректно обнаруживаются и аутентифицируются без необходимости обязательного наличия `apiKeyEnv`.
|
|
92
|
+
* ♻️ **Очистка устаревших моделей в жизненном цикле**:
|
|
93
|
+
* Исправлена ошибка, из-за которой модели в статусе `deprecated` оставались в конфигурации бессрочно после их удаления провайдером. При флаге `removeMissing: true` такие модели теперь гарантированно переводятся в статус `removed` по истечении grace-периода.
|
|
94
|
+
* ⏰ **Точный статус неактивного планировщика**:
|
|
95
|
+
* Исправлен `scheduler.status()`: при отключённой фоновой синхронизации `nextRunAt` теперь возвращает `null`, исключая вводящие в заблуждение даты следующего запуска в Web UI.
|
|
96
|
+
* 🛡️ **Надежность и масштабируемость Web UI**:
|
|
97
|
+
* Защита от сбоев сортировки при отсутствии поля `name` у модели.
|
|
98
|
+
* Замена spread-операций на итеративную агрегацию при вычислении экстремумов контекста и цены, что предотвращает переполнение стека (`RangeError`) на огромных каталогах (10 000+ моделей).
|
|
99
|
+
* Детальный вывод текста ошибки при тестировании модели по кнопке `▶`.
|
|
100
|
+
* Локализация срока давности кеша в карточке провайдера.
|
|
101
|
+
* ⚡ **Детерминированное сравнение каталогов и безопасность потоков**:
|
|
102
|
+
* Реализована стабильная сортировка ключей перед сравнением моделей, исключающая фиктивные diff-записи из-за произвольного порядка ключей в JSON.
|
|
103
|
+
* Добавлено прерывание сетевого потока при превышении лимита размера тела HTTP-запроса для защиты от утечек памяти.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
87
107
|
## 🚀 Новые возможности в v0.3.5
|
|
88
108
|
|
|
89
109
|
* ⏱️ **Адаптивная обработка Rate Limit и Retry-After**:
|
package/lib/adapter-registry.js
CHANGED
|
@@ -121,7 +121,8 @@ export function normalizeAdapterConfig(config, profile = {}) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
|
|
124
|
-
const
|
|
124
|
+
const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
|
|
125
|
+
const apiKey = keyRef ? await resolveCredential(keyRef) : ''
|
|
125
126
|
const url = new URL(descriptor.endpoint)
|
|
126
127
|
const headers = { Accept: 'application/json', ...(descriptor.headers ?? {}) }
|
|
127
128
|
if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = 'Bearer ' + apiKey
|
|
@@ -144,6 +145,12 @@ async function fetchSpecific(profile, descriptor, options) {
|
|
|
144
145
|
let payload
|
|
145
146
|
try {
|
|
146
147
|
const response = await requestSpecific(profile, descriptor, options)
|
|
148
|
+
const declared = Number(response.headers?.get?.('content-length') ?? NaN)
|
|
149
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
150
|
+
const error = new Error('model catalog response is too large')
|
|
151
|
+
error.adapterCode = 'schema'
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
147
154
|
const text = await response.text()
|
|
148
155
|
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
149
156
|
const error = new Error('model catalog response is too large')
|
package/lib/client.js
CHANGED
|
@@ -41,10 +41,10 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
document.head.appendChild(tag)
|
|
42
42
|
}
|
|
43
43
|
const en = {
|
|
44
|
-
title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd',
|
|
44
|
+
title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago',
|
|
45
45
|
}
|
|
46
46
|
const ru = {
|
|
47
|
-
title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.',
|
|
47
|
+
title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад',
|
|
48
48
|
}
|
|
49
49
|
const API = '/dsh-model-sync'
|
|
50
50
|
const CAPABILITY_LABELS = { vision: { en: 'Vision', ru: 'Зрение' }, tools: { en: 'Tools', ru: 'Инструменты' }, reasoning: { en: 'Reasoning', ru: 'Рассуждение' }, embeddings: { en: 'Embeddings', ru: 'Эмбеддинги' } }
|
|
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
|
|
|
60
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: '' })
|
|
61
61
|
const [pageSize, setPageSize] = React.useState(50); const [quickFilter, setQuickFilter] = React.useState({ context100k: false, cheap: false })
|
|
62
62
|
const [tryState, setTryState] = React.useState({})
|
|
63
|
-
const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
|
|
63
|
+
const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status, error: b.error } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
|
|
64
64
|
const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
|
|
65
65
|
const loadCredentials = React.useCallback(() => { setError(''); return fetch(`${API}/credentials`).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setCredentialStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
|
|
66
66
|
React.useEffect(() => { ensureStyles(); load(); loadCredentials(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load, loadCredentials])
|
|
@@ -82,7 +82,7 @@ window.__ModuleLoader__.load({
|
|
|
82
82
|
const tokens = searchQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
83
83
|
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;
|
|
84
84
|
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; });
|
|
85
|
-
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.localeCompare(b.name); }); 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)
|
|
85
|
+
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)
|
|
86
86
|
const renderNotifications = () => {
|
|
87
87
|
const entries = status?.notifications ?? []
|
|
88
88
|
if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
|
|
@@ -124,9 +124,9 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
const healthEntry = status?.lastHealth?.results?.find((item) => item.provider === row.provider)
|
|
125
125
|
const latencyText = healthEntry?.latencyMs ? ` · ${healthEntry.latencyMs}ms` : ''
|
|
126
126
|
const isOffline = latestRun?.status !== 'ok' && modelCount > 0
|
|
127
|
-
const offlineText = isOffline ?
|
|
127
|
+
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))) : ''
|
|
128
128
|
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)))
|
|
129
|
-
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: 'Поиск id/name/tag', 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'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), 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 && model.contextWindow >=
|
|
129
|
+
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: 'Поиск id/name/tag', 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'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), 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
|
|
130
130
|
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
|
|
131
131
|
return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, selectionEditor, policyEditor)
|
|
132
132
|
}),
|
package/lib/generic-adapter.js
CHANGED
|
@@ -38,7 +38,8 @@ function requestHeaders(apiKey) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal }) {
|
|
41
|
-
const
|
|
41
|
+
const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
|
|
42
|
+
const apiKey = keyRef ? await resolveCredential(keyRef) : ''
|
|
42
43
|
const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey), signal })
|
|
43
44
|
if (!response.ok) throw catalogRequestError(response.status, response)
|
|
44
45
|
return response
|
package/lib/http.js
CHANGED
|
@@ -19,14 +19,25 @@ function trusted(req) {
|
|
|
19
19
|
function readJson(req) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
21
|
let raw = ''
|
|
22
|
+
let failed = false
|
|
22
23
|
req.on('data', (chunk) => {
|
|
24
|
+
if (failed) return
|
|
23
25
|
raw += chunk
|
|
24
|
-
if (raw.length > 64 * 1024)
|
|
26
|
+
if (raw.length > 64 * 1024) {
|
|
27
|
+
failed = true
|
|
28
|
+
const err = new Error('request body is too large')
|
|
29
|
+
if (typeof req.destroy === 'function') req.destroy(err)
|
|
30
|
+
reject(err)
|
|
31
|
+
}
|
|
25
32
|
})
|
|
26
33
|
req.on('end', () => {
|
|
34
|
+
if (failed) return
|
|
27
35
|
try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
|
|
28
36
|
})
|
|
29
|
-
req.on('error',
|
|
37
|
+
req.on('error', (err) => {
|
|
38
|
+
if (failed) return
|
|
39
|
+
reject(err)
|
|
40
|
+
})
|
|
30
41
|
})
|
|
31
42
|
}
|
|
32
43
|
|
package/lib/index.js
CHANGED
|
@@ -142,9 +142,9 @@ export function apply(ctx, config) {
|
|
|
142
142
|
const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
|
|
143
143
|
reconfigure = () => scheduler.reconfigure()
|
|
144
144
|
scheduler.start()
|
|
145
|
+
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
145
146
|
ctx.effect(() => {
|
|
146
147
|
const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
|
|
147
|
-
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
148
148
|
return () => {
|
|
149
149
|
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
|
150
150
|
}
|
package/lib/inventory.js
CHANGED
|
@@ -52,7 +52,7 @@ function liveProviders(llm) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function isApiKeyProvider(entry, profile) {
|
|
55
|
-
if (profile?.apiKeyEnv) return true
|
|
55
|
+
if (profile?.apiKeyEnv || profile?.credentialRef || profile?.apiKeyRef) return true
|
|
56
56
|
return entry?.declared === false && API_KEY_PROVIDER_SET.has(entry.provider)
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -74,7 +74,8 @@ export function listApiKeyProviders(llm, settings) {
|
|
|
74
74
|
declared: entry.declared === true,
|
|
75
75
|
configured: Object.prototype.hasOwnProperty.call(profiles, entry.provider),
|
|
76
76
|
live: live.has(entry.provider),
|
|
77
|
-
apiKeyEnv: profile.apiKeyEnv ?? '',
|
|
77
|
+
apiKeyEnv: profile.apiKeyEnv ?? profile.credentialRef ?? profile.apiKeyRef ?? '',
|
|
78
|
+
credentialRef: profile.credentialRef ?? profile.apiKeyRef ?? profile.apiKeyEnv ?? '',
|
|
78
79
|
baseURL: profile.baseURL ?? '',
|
|
79
80
|
api: profile.api ?? '',
|
|
80
81
|
models: normalizeModels(entry.provider, profile.models ?? []),
|
package/lib/lifecycle.js
CHANGED
|
@@ -92,7 +92,7 @@ export function updateLifecycle(previous, beforeModels, advertisedModels, {
|
|
|
92
92
|
record.firstMissingAt ||= now
|
|
93
93
|
record.lastObservedRun = runNumber
|
|
94
94
|
if (record.status !== 'deprecated') record.status = 'stale'
|
|
95
|
-
if (removeMissing && record.status === 'stale' && record.consecutiveMissing >= staleGraceRuns) {
|
|
95
|
+
if (removeMissing && (record.status === 'stale' || record.status === 'deprecated') && record.consecutiveMissing >= staleGraceRuns) {
|
|
96
96
|
record.status = 'removed'
|
|
97
97
|
record.removedAt = now
|
|
98
98
|
record.reason = `missing for ${record.consecutiveMissing} discovery runs`
|
package/lib/models.js
CHANGED
|
@@ -227,6 +227,14 @@ export function normalizeModels(provider, rows) {
|
|
|
227
227
|
return out
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
export function stableStringify(value) {
|
|
231
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
232
|
+
if (value && typeof value === 'object') {
|
|
233
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
234
|
+
}
|
|
235
|
+
return JSON.stringify(value)
|
|
236
|
+
}
|
|
237
|
+
|
|
230
238
|
export function diffModels(previous, next) {
|
|
231
239
|
const sortModels = (rows) => normalizeModels('', rows).sort((left, right) => left.id.localeCompare(right.id))
|
|
232
240
|
const beforeRows = sortModels(previous)
|
|
@@ -240,7 +248,7 @@ export function diffModels(previous, next) {
|
|
|
240
248
|
const renamedBefore = new Set()
|
|
241
249
|
for (const [id, model] of after) {
|
|
242
250
|
if (!before.has(id)) continue
|
|
243
|
-
if (
|
|
251
|
+
if (stableStringify(before.get(id)) !== stableStringify(model)) {
|
|
244
252
|
changed.push({ before: before.get(id), after: model })
|
|
245
253
|
}
|
|
246
254
|
}
|
package/lib/reconcile.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { diffModels, normalizeModels } from './models.js'
|
|
1
|
+
import { diffModels, normalizeModels, stableStringify } from './models.js'
|
|
2
2
|
|
|
3
3
|
function sameModel(left, right) {
|
|
4
|
-
return
|
|
4
|
+
return stableStringify(left) === stableStringify(right)
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export function reconcileModels(provider, current, discovered, {
|
package/lib/scheduler.js
CHANGED
|
@@ -128,14 +128,14 @@ export function createSyncScheduler(sync, {
|
|
|
128
128
|
const config = getConfig() ?? {}
|
|
129
129
|
resetStates(config)
|
|
130
130
|
const values = [...state.values()]
|
|
131
|
-
const nextAt = values.length > 0 ? Math.min(...values.map((row) => row.nextAt)) : null
|
|
131
|
+
const nextAt = (timer !== null && values.length > 0) ? Math.min(...values.map((row) => row.nextAt)) : null
|
|
132
132
|
const lastRunAt = values.filter((row) => row.lastRunAt).reduce((max, row) => Math.max(max, row.lastRunAt), 0) || null
|
|
133
133
|
return {
|
|
134
134
|
active: timer !== null,
|
|
135
135
|
running,
|
|
136
136
|
lastRunAt,
|
|
137
137
|
nextRunAt: nextAt,
|
|
138
|
-
providers: Object.fromEntries(values.map((row) => [row.provider ?? '*', { lastRunAt: row.lastRunAt, nextRunAt: row.nextAt, lastStatus: row.lastStatus, ...(row.lastError ? { lastError: row.lastError } : {}) }])),
|
|
138
|
+
providers: Object.fromEntries(values.map((row) => [row.provider ?? '*', { lastRunAt: row.lastRunAt, nextRunAt: timer !== null ? row.nextAt : null, lastStatus: row.lastStatus, ...(row.lastError ? { lastError: row.lastError } : {}) }])),
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
package/lib/synchronizer.js
CHANGED
|
@@ -518,7 +518,14 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
518
518
|
// Use health as lightweight probe; latency is the signal
|
|
519
519
|
const result = await health({ provider })
|
|
520
520
|
const entry = result?.results?.find((r) => r.provider === provider)
|
|
521
|
-
return {
|
|
521
|
+
return {
|
|
522
|
+
provider,
|
|
523
|
+
model,
|
|
524
|
+
ok: entry?.status === 'ok',
|
|
525
|
+
latencyMs: Date.now() - start,
|
|
526
|
+
status: entry?.status ?? 'unknown',
|
|
527
|
+
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
528
|
+
}
|
|
522
529
|
}
|
|
523
530
|
|
|
524
531
|
async function rollbackHistory({ historyId, provider } = {}) {
|
|
@@ -587,7 +594,6 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
587
594
|
const nextModels = selectedModels.length
|
|
588
595
|
? selectedModels.map((id) => availableById.get(id))
|
|
589
596
|
: (safeAvailable.length > 0 ? safeAvailable : current)
|
|
590
|
-
const next = catalogPatch(section, provider, nextModels)
|
|
591
597
|
const previousModelSelections = structuredClone(config.modelSelections ?? {})
|
|
592
598
|
const modelSelections = { ...previousModelSelections, [provider]: selectedModels }
|
|
593
599
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-model-sync",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -38,10 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"client": {
|
|
40
40
|
"platform": "web",
|
|
41
|
-
"inject": [
|
|
42
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
43
|
-
"@deepseek-ai/dsh-client-ui-slots"
|
|
44
|
-
]
|
|
41
|
+
"inject": []
|
|
45
42
|
}
|
|
46
43
|
},
|
|
47
44
|
"scripts": {
|
|
@@ -53,4 +50,4 @@
|
|
|
53
50
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
54
51
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
55
52
|
}
|
|
56
|
-
}
|
|
53
|
+
}
|