@goodandready/dsh-model-sync 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ OAuth, subscription, and tool-only routes are intentionally outside the scope.
12
12
 
13
13
  ## Status
14
14
 
15
- Runtime, scheduler, HTTP API, reconciliation, and Settings UI are implemented. Release and staging verification remain before publication.
15
+ Runtime, scheduler, HTTP API, reconciliation, model allowlists, and Settings UI are implemented. The release workflow includes staging and production verification before publication.
16
16
 
17
17
  ## Development
18
18
 
@@ -33,3 +33,13 @@ Apply is explicit:
33
33
  ```json
34
34
  { "provider": "openai", "dryRun": false, "removeMissing": false }
35
35
  ```
36
+
37
+ Omit `provider` to discover all enabled API-key providers in one run. The Settings UI exposes this as **Refresh all**.
38
+
39
+ `POST /dsh-model-sync/selection` stores and applies a per-provider model allowlist:
40
+
41
+ ```json
42
+ { "provider": "openai", "models": ["gpt-5", "gpt-5-mini"] }
43
+ ```
44
+
45
+ An empty `models` array means all models in the latest available catalog. The selected catalog is written to DSH's `llm-pi-ai` settings, so the standard DSH model picker refreshes from the resulting list.
package/lib/client.js CHANGED
@@ -5,10 +5,10 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
  const en = {
8
- title: 'Model synchronization', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', 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',
8
+ title: 'Model synchronization', 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', selectModels: 'Choose models', saveSelection: 'Save and apply selection', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.',
9
9
  }
10
10
  const ru = {
11
- title: 'Синхронизация моделей', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск',
11
+ title: 'Синхронизация моделей', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.',
12
12
  }
13
13
  const API = '/dsh-model-sync'
14
14
  function useLocale(ctx) {
@@ -17,16 +17,21 @@ window.__ModuleLoader__.load({
17
17
  function ModelSyncSection({ ctx }) {
18
18
  const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
19
19
  const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState('')
20
+ const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([])
20
21
  const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
21
22
  React.useEffect(() => { load(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load])
22
- const run = () => { setBusy(true); setError(''); fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: provider || undefined, dryRun }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
23
- const rows = status?.providers ?? []; const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, style: { marginRight: 8 } }, label)
23
+ const execute = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider || undefined, dryRun }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
24
+ const openSelection = (row) => { const available = row.availableModels ?? row.models ?? []; setSelectionProvider(row.provider); setSelectionDraft(row.selectedModels?.length ? row.selectedModels : available.map((model) => model.id)) }
25
+ const closeSelection = () => { setSelectionProvider(''); setSelectionDraft([]) }
26
+ const saveSelection = () => { const row = rows.find((item) => item.provider === selectionProvider); const available = row?.availableModels ?? row?.models ?? []; 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)) }
27
+ const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? []; const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, style: { marginRight: 8 } }, label)
24
28
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 680 } },
25
29
  React.createElement('p', null, t.desc),
26
30
  React.createElement('div', null, 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}`)),
27
- React.createElement('div', null, button(t.refresh, load), button(dryRun ? t.run : t.apply, run)),
31
+ React.createElement('div', null, button(t.refresh, load), button(t.refreshAll, () => execute(undefined)), button(dryRun ? t.run : t.apply, () => execute(provider || undefined))),
28
32
  status?.lastRun ? React.createElement('small', null, `${t.lastRun}: ${new Date(status.lastRun.finishedAt).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,
29
- rows.map((row) => React.createElement('div', { key: row.provider }, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${row.models?.length ?? 0} models`)),
33
+ rows.map((row) => React.createElement('div', { key: row.provider, style: { display: 'flex', alignItems: 'center', gap: 8 } }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${row.models?.length ?? 0} models`), button(t.selectModels, () => openSelection(row)))),
34
+ selectionProvider ? React.createElement('div', { style: { border: '1px solid var(--dsw-alias-border-primary)', padding: 12, borderRadius: 6 } }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : choices.map((model) => React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0' } }, 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})`))), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null,
30
35
  )
31
36
  }
32
37
  function apply(ctx) { ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-model-sync', order: 25, label: () => 'Model Sync' }, (props) => React.createElement(ModelSyncSection, { ...props, ctx }))) }
package/lib/http.js CHANGED
@@ -40,6 +40,23 @@ export function normalizeRunRequest(body) {
40
40
  }
41
41
  }
42
42
 
43
+ export function normalizeSelectionRequest(body) {
44
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
45
+ if (typeof body.provider !== 'string' || !body.provider || body.provider.length > 128) throw new Error('provider must be a short string')
46
+ if (!Array.isArray(body.models) || body.models.length > 5000) throw new Error('models must be an array')
47
+ const models = []
48
+ const seen = new Set()
49
+ for (const model of body.models) {
50
+ if (typeof model !== 'string' || model.length > 512) throw new Error('model ids must be short strings')
51
+ const id = model.trim()
52
+ if (id && !seen.has(id)) {
53
+ seen.add(id)
54
+ models.push(id)
55
+ }
56
+ }
57
+ return { provider: body.provider, models }
58
+ }
59
+
43
60
  export function registerHttpApi(ctx, synchronizer) {
44
61
  const makeHandler = (route) => async (req, res) => {
45
62
  if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
@@ -49,16 +66,22 @@ export function registerHttpApi(ctx, synchronizer) {
49
66
  }
50
67
  if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
51
68
  let options
52
- try { options = normalizeRunRequest(await readJson(req)) }
69
+ try { options = route === 'selection' ? normalizeSelectionRequest(await readJson(req)) : normalizeRunRequest(await readJson(req)) }
53
70
  catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
54
- try { const result = await synchronizer.run(options); return json(res, 200, { ...result, providers: synchronizer.listProviders() }) }
71
+ try {
72
+ const result = route === 'selection'
73
+ ? await synchronizer.setModelSelection(options.provider, options.models)
74
+ : await synchronizer.run(options)
75
+ return json(res, 200, { ...result, providers: synchronizer.listProviders() })
76
+ }
55
77
  catch (error) {
56
- const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' ? 503 : 500
78
+ const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' ? 400 : 500
57
79
  return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
58
80
  }
59
81
  }
60
82
  return [
61
83
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
62
84
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
85
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
63
86
  ]
64
87
  }
package/lib/index.js CHANGED
@@ -17,23 +17,34 @@ export const Config = Schema.object({
17
17
  provider: Schema.string().required(),
18
18
  enabled: Schema.boolean().default(true),
19
19
  })).default([]),
20
+ modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
20
21
  })
21
22
 
22
23
  export function apply(ctx, config) {
23
24
  let liveConfig = Config(structuredClone(config || {}))
24
25
  let reconfigure = () => {}
26
+ let saveConfig = async () => {
27
+ throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
28
+ }
25
29
  const settings = ctx.get('settings')
26
30
  if (settings?.settings?.register) {
27
31
  const scope = settings.settings.register(NS, Config, { base: config })
28
32
  liveConfig = Config(scope.get() || config)
29
- ctx.effect(() => scope.onChange((next) => {
33
+ const watch = scope.watch ?? scope.onChange
34
+ ctx.effect(() => watch?.call(scope, (next) => {
30
35
  liveConfig = Config(next || config)
31
36
  reconfigure()
32
37
  }), 'dsh-model-sync: settings')
38
+ saveConfig = async (patch) => {
39
+ if (typeof scope.update === 'function') return scope.update(patch)
40
+ const next = { ...scope.get(), ...patch }
41
+ if (typeof scope.replace === 'function') return scope.replace(next)
42
+ throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
43
+ }
33
44
  }
34
45
 
35
46
  const service = createProviderService(ctx)
36
- const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig })
47
+ const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig, saveConfig })
37
48
  const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
38
49
  reconfigure = () => scheduler.reconfigure()
39
50
  scheduler.start()
@@ -2,6 +2,7 @@ import { listApiKeyProviders } from './inventory.js'
2
2
  import { createProviderAdapterRegistry } from './adapter-registry.js'
3
3
  import { createCredentialResolver } from './credentials.js'
4
4
  import { reconcileModels, catalogPatch } from './reconcile.js'
5
+ import { normalizeModels } from './models.js'
5
6
 
6
7
  const PIAI_NS = 'llm-pi-ai'
7
8
 
@@ -23,14 +24,31 @@ function errorMessage(error) {
23
24
  return error instanceof Error ? error.message : String(error)
24
25
  }
25
26
 
26
- export function createModelSynchronizer(ctx, { getConfig = () => ({}), fetchImpl = fetch } = {}) {
27
+ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch } = {}) {
27
28
  const resolveCredential = createCredentialResolver(ctx)
28
29
  const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl })
29
30
  let running = null
30
31
  let lastRun = null
31
32
 
32
33
  function providers() {
33
- return listApiKeyProviders(ctx.llm, settingsOf(ctx))
34
+ const config = getConfig() ?? {}
35
+ const lastResults = new Map((lastRun?.results ?? []).map((row) => [row.provider, row]))
36
+ return listApiKeyProviders(ctx.llm, settingsOf(ctx)).map((row) => {
37
+ const selection = Array.isArray(config.modelSelections?.[row.provider])
38
+ ? [...new Set(config.modelSelections[row.provider].filter((id) => typeof id === 'string' && id))]
39
+ : []
40
+ const latest = lastResults.get(row.provider)
41
+ return {
42
+ ...row,
43
+ selectedModels: selection,
44
+ availableModels: latest?.status === 'ok' ? latest.availableModels ?? latest.models ?? row.models : row.models,
45
+ }
46
+ })
47
+ }
48
+
49
+ function selectionFor(config, provider) {
50
+ const selection = config?.modelSelections?.[provider]
51
+ return Array.isArray(selection) ? [...new Set(selection.filter((id) => typeof id === 'string' && id))] : []
34
52
  }
35
53
 
36
54
  async function discover(row, { signal } = {}) {
@@ -68,8 +86,21 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), fetchImpl
68
86
  }
69
87
  const settings = settingsOf(ctx)
70
88
  const current = profileOf(settings, row.provider).models ?? row.models
71
- const reconciliation = reconcileModels(row.provider, current, discovered.models, { removeMissing })
72
- results.push({ ...discovered, ...reconciliation, changed: reconciliation.changed })
89
+ const selectedModels = selectionFor(config, row.provider)
90
+ const advertised = selectedModels.length
91
+ ? discovered.models.filter((model) => selectedModels.includes(model.id))
92
+ : discovered.models
93
+ const effectiveRemoveMissing = removeMissing || selectedModels.length > 0
94
+ const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: effectiveRemoveMissing })
95
+ results.push({
96
+ ...discovered,
97
+ availableModels: discovered.models,
98
+ selectedModels,
99
+ advertised,
100
+ ...reconciliation,
101
+ removeMissing: effectiveRemoveMissing,
102
+ changed: reconciliation.changed,
103
+ })
73
104
  }
74
105
  const result = { startedAt, finishedAt: Date.now(), dryRun, removeMissing, results, applied: false }
75
106
  if (!dryRun && results.some((row) => row.status === 'ok' && row.changed)) {
@@ -93,11 +124,43 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), fetchImpl
93
124
  return running
94
125
  }
95
126
 
127
+ async function setModelSelection(provider, models) {
128
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
129
+ if (!Array.isArray(models)) throw new Error('models must be an array')
130
+ const selectedModels = [...new Set(models.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
131
+ const config = getConfig() ?? {}
132
+ const row = providers().find((item) => item.provider === provider)
133
+ if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
134
+ const latest = lastRun?.results?.find((item) => item.provider === provider && item.status === 'ok')
135
+ const available = normalizeModels(provider, latest?.availableModels ?? latest?.models ?? row.availableModels ?? row.models)
136
+ const availableById = new Map(available.map((model) => [model.id, model]))
137
+ const unknown = selectedModels.filter((id) => !availableById.has(id))
138
+ if (unknown.length > 0) throw Object.assign(new Error(`unknown models for ${provider}: ${unknown.join(', ')}`), { code: 'UNKNOWN_MODEL' })
139
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
140
+
141
+ const settings = settingsOf(ctx)
142
+ const section = settings?.get?.(PIAI_NS)
143
+ const descriptor = descriptorOf(settings, PIAI_NS)
144
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
145
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
146
+ }
147
+ const current = normalizeModels(provider, profileOf(settings, provider).models ?? row.models)
148
+ const nextModels = selectedModels.length
149
+ ? selectedModels.map((id) => availableById.get(id))
150
+ : (available.length > 0 ? available : current)
151
+ const next = catalogPatch(section, provider, nextModels)
152
+ await settings.replace(PIAI_NS, next, descriptor.revision)
153
+ const modelSelections = { ...(config.modelSelections ?? {}), [provider]: selectedModels }
154
+ await saveConfig({ modelSelections })
155
+ return { provider, selectedModels, models: nextModels, availableModels: available, applied: true }
156
+ }
157
+
96
158
  return Object.freeze({
97
159
  listProviders: providers,
98
160
  getProvider: (provider) => providers().find((row) => row.provider === provider) ?? null,
99
161
  discover,
100
162
  run,
163
+ setModelSelection,
101
164
  status: () => ({ running: Boolean(running), lastRun }),
102
165
  })
103
166
  }
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
- "keywords": ["deepseek-harness", "dsh", "dsh-plugin", "models", "api-key", "catalog"],
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "models",
10
+ "api-key",
11
+ "catalog"
12
+ ],
6
13
  "repository": {
7
14
  "type": "git",
8
15
  "url": "https://github.com/GooDAnDReaDY/dsh-model-sync.git"
@@ -19,7 +26,12 @@
19
26
  "./package.json": "./package.json",
20
27
  "./cordis.patch.yml": "./cordis.patch.yml"
21
28
  },
22
- "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
29
+ "files": [
30
+ "lib",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
23
35
  "dsh": {
24
36
  "bundle": {
25
37
  "patch": "./cordis.patch.yml"