@goodandready/dsh-model-sync 0.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/cordis.patch.yml +4 -0
- package/lib/adapter-registry.js +87 -0
- package/lib/client.js +35 -0
- package/lib/credentials.js +21 -0
- package/lib/generic-adapter.js +67 -0
- package/lib/http.js +64 -0
- package/lib/index.js +53 -0
- package/lib/inventory.js +84 -0
- package/lib/models.js +62 -0
- package/lib/provider-service.js +20 -0
- package/lib/reconcile.js +50 -0
- package/lib/scheduler.js +37 -0
- package/lib/synchronizer.js +103 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GooDAnDReaDY
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @goodandready/dsh-model-sync
|
|
2
|
+
|
|
3
|
+
Automatic model catalog synchronization for API-key providers in DeepSeek Harness.
|
|
4
|
+
|
|
5
|
+
The plugin is designed for:
|
|
6
|
+
|
|
7
|
+
- API-key providers already configured in DSH;
|
|
8
|
+
- API-key providers available in the built-in DSH catalog but not configured yet;
|
|
9
|
+
- future custom API-key provider routes.
|
|
10
|
+
|
|
11
|
+
OAuth, subscription, and tool-only routes are intentionally outside the scope.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
Runtime, scheduler, HTTP API, reconciliation, and Settings UI are implemented. Release and staging verification remain before publication.
|
|
16
|
+
|
|
17
|
+
## Development
|
|
18
|
+
|
|
19
|
+
See the project documentation under ``docs/`` for architecture, research, testing, and release notes.
|
|
20
|
+
|
|
21
|
+
## API and safety
|
|
22
|
+
|
|
23
|
+
`GET /dsh-model-sync/status` reports provider state without secrets.
|
|
24
|
+
|
|
25
|
+
`POST /dsh-model-sync/run` defaults to a read-only dry-run:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{ "provider": "openai", "dryRun": true }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Apply is explicit:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{ "provider": "openai", "dryRun": false, "removeMissing": false }
|
|
35
|
+
```
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { canUseGenericAdapter, createGenericAdapter } from './generic-adapter.js'
|
|
2
|
+
import { normalizeModels } from './models.js'
|
|
3
|
+
|
|
4
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
const PROVIDER_ADAPTERS = Object.freeze({
|
|
7
|
+
anthropic: { endpoint: 'https://api.anthropic.com/v1/models', auth: 'x-api-key', headers: { 'anthropic-version': '2023-06-01' } },
|
|
8
|
+
google: { endpoint: 'https://generativelanguage.googleapis.com/v1beta/models', auth: 'query-key', parse: 'google' },
|
|
9
|
+
openai: { endpoint: 'https://api.openai.com/v1/models', auth: 'bearer' },
|
|
10
|
+
deepseek: { endpoint: 'https://api.deepseek.com/models', auth: 'bearer' },
|
|
11
|
+
xai: { endpoint: 'https://api.x.ai/v1/models', auth: 'bearer' },
|
|
12
|
+
openrouter: { endpoint: 'https://openrouter.ai/api/v1/models', auth: 'bearer' },
|
|
13
|
+
groq: { endpoint: 'https://api.groq.com/openai/v1/models', auth: 'bearer' },
|
|
14
|
+
mistral: { endpoint: 'https://api.mistral.ai/v1/models', auth: 'bearer' },
|
|
15
|
+
'ant-ling': { endpoint: 'https://api.ant-ling.com/v1/models', auth: 'bearer' },
|
|
16
|
+
cerebras: { endpoint: 'https://api.cerebras.ai/v1/models', auth: 'bearer' },
|
|
17
|
+
fireworks: { endpoint: 'https://api.fireworks.ai/inference/v1/models', auth: 'bearer' },
|
|
18
|
+
huggingface: { endpoint: 'https://router.huggingface.co/v1/models', auth: 'bearer' },
|
|
19
|
+
'moonshotai': { endpoint: 'https://api.moonshot.ai/v1/models', auth: 'bearer' },
|
|
20
|
+
'moonshotai-cn': { endpoint: 'https://api.moonshot.cn/v1/models', auth: 'bearer' },
|
|
21
|
+
nvidia: { endpoint: 'https://integrate.api.nvidia.com/v1/models', auth: 'bearer' },
|
|
22
|
+
'qwen-token-plan': { endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models', auth: 'bearer' },
|
|
23
|
+
'qwen-token-plan-cn': { endpoint: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models', auth: 'bearer' },
|
|
24
|
+
together: { endpoint: 'https://api.together.ai/v1/models', auth: 'bearer' },
|
|
25
|
+
'xiaomi': { endpoint: 'https://api.xiaomimimo.com/v1/models', auth: 'bearer' },
|
|
26
|
+
'xiaomi-token-plan-ams': { endpoint: 'https://token-plan-ams.xiaomimimo.com/v1/models', auth: 'bearer' },
|
|
27
|
+
'xiaomi-token-plan-cn': { endpoint: 'https://token-plan-cn.xiaomimimo.com/v1/models', auth: 'bearer' },
|
|
28
|
+
'xiaomi-token-plan-sgp': { endpoint: 'https://token-plan-sgp.xiaomimimo.com/v1/models', auth: 'bearer' },
|
|
29
|
+
zai: { endpoint: 'https://api.z.ai/api/coding/paas/v4/models', auth: 'bearer' },
|
|
30
|
+
'zai-coding-cn': { endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4/models', auth: 'bearer' },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function parseRows(provider, payload, parser) {
|
|
34
|
+
if (parser === 'google') {
|
|
35
|
+
const rows = Array.isArray(payload?.models) ? payload.models : []
|
|
36
|
+
return normalizeModels(provider, rows.map((row) => ({
|
|
37
|
+
...row,
|
|
38
|
+
id: String(row.name ?? '').replace(/^models\//, ''),
|
|
39
|
+
name: row.displayName ?? row.display_name ?? row.name,
|
|
40
|
+
contextWindow: row.inputTokenLimit,
|
|
41
|
+
maxTokens: row.outputTokenLimit,
|
|
42
|
+
})))
|
|
43
|
+
}
|
|
44
|
+
const rows = payload?.data ?? payload?.models ?? payload?.availableModels
|
|
45
|
+
return normalizeModels(provider, Array.isArray(rows) ? rows : [])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
|
|
49
|
+
const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
|
|
50
|
+
const url = new URL(profile.baseURL ? `${String(profile.baseURL).replace(/\/+$/, '')}/models` : descriptor.endpoint)
|
|
51
|
+
const headers = { Accept: 'application/json', ...(descriptor.headers ?? {}) }
|
|
52
|
+
if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
53
|
+
if (descriptor.auth === 'x-api-key' && apiKey) headers['x-api-key'] = apiKey
|
|
54
|
+
if (descriptor.auth === 'query-key' && apiKey) url.searchParams.set('key', apiKey)
|
|
55
|
+
const response = await fetchImpl(url, { headers, signal })
|
|
56
|
+
if (!response.ok) throw new Error(`model catalog request failed with HTTP ${response.status}`)
|
|
57
|
+
const text = await response.text()
|
|
58
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) throw new Error('model catalog response is too large')
|
|
59
|
+
let payload
|
|
60
|
+
try {
|
|
61
|
+
payload = JSON.parse(text)
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error('model catalog response is not valid JSON')
|
|
64
|
+
}
|
|
65
|
+
return parseRows(profile.provider, payload, descriptor.parse)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createProviderAdapterRegistry({ resolveCredential, fetchImpl = fetch } = {}) {
|
|
69
|
+
const generic = createGenericAdapter({ resolveCredential, fetchImpl })
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
descriptors: PROVIDER_ADAPTERS,
|
|
72
|
+
select(profile) {
|
|
73
|
+
if (canUseGenericAdapter(profile)) return generic
|
|
74
|
+
const descriptor = PROVIDER_ADAPTERS[profile?.provider]
|
|
75
|
+
if (!descriptor) return null
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
id: `provider:${profile.provider}`,
|
|
78
|
+
canHandle: () => true,
|
|
79
|
+
discover: (current, options = {}) => fetchSpecific(current, descriptor, {
|
|
80
|
+
resolveCredential,
|
|
81
|
+
fetchImpl,
|
|
82
|
+
...options,
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@goodandready/dsh-model-sync',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
var exports = module.exports
|
|
6
|
+
const React = require('react')
|
|
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',
|
|
9
|
+
}
|
|
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: 'Последний запуск',
|
|
12
|
+
}
|
|
13
|
+
const API = '/dsh-model-sync'
|
|
14
|
+
function useLocale(ctx) {
|
|
15
|
+
return React.useSyncExternalStore(React.useMemo(() => (cb) => ctx.locale?.subscribe?.(cb) ?? (() => {}), [ctx]), React.useCallback(() => ctx.locale?.getSnapshot?.().active ?? 'en', [ctx]))
|
|
16
|
+
}
|
|
17
|
+
function ModelSyncSection({ ctx }) {
|
|
18
|
+
const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
|
|
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 load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
|
|
21
|
+
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)
|
|
24
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 680 } },
|
|
25
|
+
React.createElement('p', null, t.desc),
|
|
26
|
+
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)),
|
|
28
|
+
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`)),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
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 }))) }
|
|
33
|
+
module.exports = { apply, inject: ['slots', 'locale'] }; return module.exports
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const PRINTABLE_API_KEY = /^[\x21-\x7E]+$/
|
|
2
|
+
|
|
3
|
+
export function normalizeApiKey(value) {
|
|
4
|
+
if (value === undefined || value === null) return ''
|
|
5
|
+
const key = String(value).trim()
|
|
6
|
+
if (!key) return ''
|
|
7
|
+
if (!PRINTABLE_API_KEY.test(key)) {
|
|
8
|
+
throw new Error('API key contains characters that cannot be sent in an HTTP header')
|
|
9
|
+
}
|
|
10
|
+
return key
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createCredentialResolver(ctx) {
|
|
14
|
+
const credentials = ctx?.get?.('credentials') ?? ctx?.credentials
|
|
15
|
+
return async (ref) => {
|
|
16
|
+
if (!ref) return ''
|
|
17
|
+
if (!credentials?.resolve) throw new Error('DSH credentials service is unavailable')
|
|
18
|
+
const result = await credentials.resolve(ref)
|
|
19
|
+
return normalizeApiKey(typeof result === 'string' ? result : result?.value)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { normalizeModels } from './models.js'
|
|
2
|
+
|
|
3
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
4
|
+
const SUPPORTED_APIS = new Set(['openai-completions', 'openai-responses'])
|
|
5
|
+
|
|
6
|
+
function endpointFor(baseURL) {
|
|
7
|
+
const base = String(baseURL ?? '').trim().replace(/\/+$/, '')
|
|
8
|
+
if (!base) throw new Error('missing baseURL')
|
|
9
|
+
return base.endsWith('/models') ? base : `${base}/models`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readBody(response) {
|
|
13
|
+
const declared = Number(response.headers?.get?.('content-length') ?? NaN)
|
|
14
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
15
|
+
throw new Error('model catalog response is too large')
|
|
16
|
+
}
|
|
17
|
+
const text = await response.text()
|
|
18
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
19
|
+
throw new Error('model catalog response is too large')
|
|
20
|
+
}
|
|
21
|
+
return text
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rowsFrom(payload) {
|
|
25
|
+
const rows = payload?.data ?? payload?.models ?? payload?.availableModels
|
|
26
|
+
return Array.isArray(rows) ? rows : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function canUseGenericAdapter(profile) {
|
|
30
|
+
return Boolean(profile?.baseURL) && SUPPORTED_APIS.has(profile?.api || 'openai-completions')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function discoverOpenAIModels(profile, {
|
|
34
|
+
resolveCredential,
|
|
35
|
+
fetchImpl = fetch,
|
|
36
|
+
signal,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!canUseGenericAdapter(profile)) {
|
|
39
|
+
throw new Error('generic discovery requires an OpenAI-compatible baseURL and protocol')
|
|
40
|
+
}
|
|
41
|
+
const apiKey = profile.apiKeyEnv ? await resolveCredential(profile.apiKeyEnv) : ''
|
|
42
|
+
const headers = { Accept: 'application/json' }
|
|
43
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
44
|
+
const response = await fetchImpl(endpointFor(profile.baseURL), { headers, signal })
|
|
45
|
+
if (!response.ok) throw new Error(`model catalog request failed with HTTP ${response.status}`)
|
|
46
|
+
let payload
|
|
47
|
+
try {
|
|
48
|
+
payload = JSON.parse(await readBody(response))
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error('model catalog response is not valid JSON')
|
|
51
|
+
}
|
|
52
|
+
return normalizeModels(profile.provider ?? '', rowsFrom(payload))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createGenericAdapter({ resolveCredential, fetchImpl = fetch } = {}) {
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
id: 'openai-compatible',
|
|
58
|
+
canHandle: canUseGenericAdapter,
|
|
59
|
+
discover(profile, options = {}) {
|
|
60
|
+
return discoverOpenAIModels(profile, {
|
|
61
|
+
resolveCredential,
|
|
62
|
+
fetchImpl,
|
|
63
|
+
...options,
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const BASE = '/dsh-model-sync'
|
|
2
|
+
|
|
3
|
+
function json(res, status, body) {
|
|
4
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
5
|
+
res.end(JSON.stringify(body))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function trusted(req) {
|
|
9
|
+
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
|
|
10
|
+
const origin = req.headers?.origin
|
|
11
|
+
if (!origin) return true
|
|
12
|
+
const host = req.headers?.host
|
|
13
|
+
if (!host) return false
|
|
14
|
+
try { return new URL(origin).host === host } catch { return false }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJson(req) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let raw = ''
|
|
20
|
+
req.on('data', (chunk) => {
|
|
21
|
+
raw += chunk
|
|
22
|
+
if (raw.length > 64 * 1024) reject(new Error('request body is too large'))
|
|
23
|
+
})
|
|
24
|
+
req.on('end', () => {
|
|
25
|
+
try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
|
|
26
|
+
})
|
|
27
|
+
req.on('error', reject)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeRunRequest(body) {
|
|
32
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
33
|
+
if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
|
|
34
|
+
if (body.dryRun !== undefined && typeof body.dryRun !== 'boolean') throw new Error('dryRun must be boolean')
|
|
35
|
+
if (body.removeMissing !== undefined && typeof body.removeMissing !== 'boolean') throw new Error('removeMissing must be boolean')
|
|
36
|
+
return {
|
|
37
|
+
...(body.provider ? { provider: body.provider } : {}),
|
|
38
|
+
dryRun: body.dryRun !== false,
|
|
39
|
+
removeMissing: body.removeMissing === true,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerHttpApi(ctx, synchronizer) {
|
|
44
|
+
const makeHandler = (route) => async (req, res) => {
|
|
45
|
+
if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
|
|
46
|
+
if (route === 'status') {
|
|
47
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
48
|
+
return json(res, 200, { ...synchronizer.status(), providers: synchronizer.listProviders() })
|
|
49
|
+
}
|
|
50
|
+
if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
|
|
51
|
+
let options
|
|
52
|
+
try { options = normalizeRunRequest(await readJson(req)) }
|
|
53
|
+
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() }) }
|
|
55
|
+
catch (error) {
|
|
56
|
+
const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' ? 503 : 500
|
|
57
|
+
return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
|
|
62
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
|
|
63
|
+
]
|
|
64
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
2
|
+
import { createProviderService } from './provider-service.js'
|
|
3
|
+
import { createModelSynchronizer } from './synchronizer.js'
|
|
4
|
+
import { createSyncScheduler } from './scheduler.js'
|
|
5
|
+
import { registerHttpApi } from './http.js'
|
|
6
|
+
|
|
7
|
+
export const name = '@goodandready/dsh-model-sync'
|
|
8
|
+
export const inject = ['llm', 'settings', 'webServer', 'credentials']
|
|
9
|
+
|
|
10
|
+
const NS = 'dsh-model-sync'
|
|
11
|
+
|
|
12
|
+
export const Config = Schema.object({
|
|
13
|
+
enabled: Schema.boolean().default(true),
|
|
14
|
+
intervalMinutes: Schema.number().default(60),
|
|
15
|
+
autoApply: Schema.boolean().default(false),
|
|
16
|
+
providers: Schema.array(Schema.object({
|
|
17
|
+
provider: Schema.string().required(),
|
|
18
|
+
enabled: Schema.boolean().default(true),
|
|
19
|
+
})).default([]),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export function apply(ctx, config) {
|
|
23
|
+
let liveConfig = Config(structuredClone(config || {}))
|
|
24
|
+
let reconfigure = () => {}
|
|
25
|
+
const settings = ctx.get('settings')
|
|
26
|
+
if (settings?.settings?.register) {
|
|
27
|
+
const scope = settings.settings.register(NS, Config, { base: config })
|
|
28
|
+
liveConfig = Config(scope.get() || config)
|
|
29
|
+
ctx.effect(() => scope.onChange((next) => {
|
|
30
|
+
liveConfig = Config(next || config)
|
|
31
|
+
reconfigure()
|
|
32
|
+
}), 'dsh-model-sync: settings')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const service = createProviderService(ctx)
|
|
36
|
+
const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig })
|
|
37
|
+
const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
|
|
38
|
+
reconfigure = () => scheduler.reconfigure()
|
|
39
|
+
scheduler.start()
|
|
40
|
+
ctx.effect(() => {
|
|
41
|
+
const registrations = registerHttpApi(ctx, synchronizer)
|
|
42
|
+
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
43
|
+
return () => {
|
|
44
|
+
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
|
45
|
+
}
|
|
46
|
+
}, 'dsh-model-sync: http api')
|
|
47
|
+
ctx.effect(() => {
|
|
48
|
+
if (!liveConfig.enabled) return () => {}
|
|
49
|
+
ctx.provide('modelSync', service)
|
|
50
|
+
ctx.provide('modelSyncRunner', synchronizer)
|
|
51
|
+
return () => {}
|
|
52
|
+
}, 'dsh-model-sync: service')
|
|
53
|
+
}
|
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { normalizeModels } from './models.js'
|
|
2
|
+
|
|
3
|
+
export const API_KEY_PROVIDER_IDS = Object.freeze([
|
|
4
|
+
'anthropic',
|
|
5
|
+
'ant-ling',
|
|
6
|
+
'azure-openai-responses',
|
|
7
|
+
'cerebras',
|
|
8
|
+
'cloudflare-ai-gateway',
|
|
9
|
+
'cloudflare-workers-ai',
|
|
10
|
+
'deepseek',
|
|
11
|
+
'fireworks',
|
|
12
|
+
'google',
|
|
13
|
+
'groq',
|
|
14
|
+
'huggingface',
|
|
15
|
+
'kimi-coding',
|
|
16
|
+
'minimax',
|
|
17
|
+
'minimax-cn',
|
|
18
|
+
'mistral',
|
|
19
|
+
'moonshotai',
|
|
20
|
+
'moonshotai-cn',
|
|
21
|
+
'nvidia',
|
|
22
|
+
'openai',
|
|
23
|
+
'opencode',
|
|
24
|
+
'opencode-go',
|
|
25
|
+
'openrouter',
|
|
26
|
+
'qwen-token-plan',
|
|
27
|
+
'qwen-token-plan-cn',
|
|
28
|
+
'radius',
|
|
29
|
+
'together',
|
|
30
|
+
'vercel-ai-gateway',
|
|
31
|
+
'xai',
|
|
32
|
+
'xiaomi',
|
|
33
|
+
'xiaomi-token-plan-ams',
|
|
34
|
+
'xiaomi-token-plan-cn',
|
|
35
|
+
'xiaomi-token-plan-sgp',
|
|
36
|
+
'zai',
|
|
37
|
+
'zai-coding-cn',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const API_KEY_PROVIDER_SET = new Set(API_KEY_PROVIDER_IDS)
|
|
41
|
+
|
|
42
|
+
function settingsProfiles(settings) {
|
|
43
|
+
try {
|
|
44
|
+
return settings?.get('llm-pi-ai')?.providers ?? {}
|
|
45
|
+
} catch {
|
|
46
|
+
return {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function liveProviders(llm) {
|
|
51
|
+
return new Map((llm?.listProviders?.() ?? []).map((row) => [row.id, row]))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isApiKeyProvider(entry, profile) {
|
|
55
|
+
if (profile?.apiKeyEnv) return true
|
|
56
|
+
return entry?.declared === false && API_KEY_PROVIDER_SET.has(entry.provider)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function listApiKeyProviders(llm, settings) {
|
|
60
|
+
const profiles = settingsProfiles(settings)
|
|
61
|
+
const live = liveProviders(llm)
|
|
62
|
+
const directory = llm?.listConfigurableProviders?.() ?? []
|
|
63
|
+
const out = []
|
|
64
|
+
const seen = new Set()
|
|
65
|
+
for (const entry of directory) {
|
|
66
|
+
const profile = profiles[entry.provider] ?? {}
|
|
67
|
+
if (!isApiKeyProvider(entry, profile) || seen.has(entry.provider)) continue
|
|
68
|
+
seen.add(entry.provider)
|
|
69
|
+
out.push({
|
|
70
|
+
provider: entry.provider,
|
|
71
|
+
displayName: entry.displayName ?? entry.provider,
|
|
72
|
+
settingsNs: entry.settingsNs,
|
|
73
|
+
settingsPath: [...(entry.settingsPath ?? [])],
|
|
74
|
+
declared: entry.declared === true,
|
|
75
|
+
configured: Object.prototype.hasOwnProperty.call(profiles, entry.provider),
|
|
76
|
+
live: live.has(entry.provider),
|
|
77
|
+
apiKeyEnv: profile.apiKeyEnv ?? '',
|
|
78
|
+
baseURL: profile.baseURL ?? '',
|
|
79
|
+
api: profile.api ?? '',
|
|
80
|
+
models: normalizeModels(entry.provider, profile.models ?? []),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const FIELD_ALIASES = Object.freeze({
|
|
2
|
+
contextWindow: ['contextWindow', 'context_window', 'contextLength', 'context_length'],
|
|
3
|
+
maxTokens: ['maxTokens', 'max_tokens', 'max_output_tokens', 'maxOutputTokens'],
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
function firstNumber(row, fields) {
|
|
7
|
+
for (const field of fields) {
|
|
8
|
+
const value = Number(row?.[field])
|
|
9
|
+
if (Number.isFinite(value) && value > 0) return value
|
|
10
|
+
}
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeModel(provider, row) {
|
|
15
|
+
if (typeof row === 'string') {
|
|
16
|
+
row = { id: row }
|
|
17
|
+
}
|
|
18
|
+
if (!row || typeof row !== 'object') return null
|
|
19
|
+
const id = String(row.id ?? row.slug ?? row.model ?? '').trim()
|
|
20
|
+
if (!id) return null
|
|
21
|
+
const name = String(row.name ?? row.displayName ?? row.display_name ?? id).trim() || id
|
|
22
|
+
const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
|
|
23
|
+
const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
|
|
24
|
+
return {
|
|
25
|
+
provider,
|
|
26
|
+
id,
|
|
27
|
+
name,
|
|
28
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
29
|
+
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
30
|
+
...(row.description ? { description: String(row.description) } : {}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeModels(provider, rows) {
|
|
35
|
+
const out = []
|
|
36
|
+
const seen = new Set()
|
|
37
|
+
for (const row of rows ?? []) {
|
|
38
|
+
const model = normalizeModel(provider, row)
|
|
39
|
+
if (!model || seen.has(model.id)) continue
|
|
40
|
+
seen.add(model.id)
|
|
41
|
+
out.push(model)
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function diffModels(previous, next) {
|
|
47
|
+
const before = new Map(normalizeModels('', previous).map((row) => [row.id, row]))
|
|
48
|
+
const after = new Map(normalizeModels('', next).map((row) => [row.id, row]))
|
|
49
|
+
const added = []
|
|
50
|
+
const removed = []
|
|
51
|
+
const changed = []
|
|
52
|
+
for (const [id, model] of after) {
|
|
53
|
+
if (!before.has(id)) added.push(model)
|
|
54
|
+
else if (JSON.stringify(before.get(id)) !== JSON.stringify(model)) {
|
|
55
|
+
changed.push({ before: before.get(id), after: model })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [id, model] of before) {
|
|
59
|
+
if (!after.has(id)) removed.push(model)
|
|
60
|
+
}
|
|
61
|
+
return { added, removed, changed, hasChanges: added.length > 0 || removed.length > 0 || changed.length > 0 }
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listApiKeyProviders } from './inventory.js'
|
|
2
|
+
|
|
3
|
+
export function createProviderService(ctx) {
|
|
4
|
+
const readSettings = () => {
|
|
5
|
+
try {
|
|
6
|
+
return ctx.get('settings')
|
|
7
|
+
} catch {
|
|
8
|
+
return undefined
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const listProviders = () => listApiKeyProviders(ctx.llm, readSettings())
|
|
13
|
+
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
listProviders,
|
|
16
|
+
getProvider(provider) {
|
|
17
|
+
return listProviders().find((row) => row.provider === provider) ?? null
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
}
|
package/lib/reconcile.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { diffModels, normalizeModels } from './models.js'
|
|
2
|
+
|
|
3
|
+
function sameModel(left, right) {
|
|
4
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function reconcileModels(provider, current, discovered, {
|
|
8
|
+
removeMissing = false,
|
|
9
|
+
} = {}) {
|
|
10
|
+
const before = normalizeModels(provider, current)
|
|
11
|
+
const advertised = normalizeModels(provider, discovered)
|
|
12
|
+
const advertisedById = new Map(advertised.map((row) => [row.id, row]))
|
|
13
|
+
const next = []
|
|
14
|
+
const stale = []
|
|
15
|
+
for (const row of before) {
|
|
16
|
+
const fresh = advertisedById.get(row.id)
|
|
17
|
+
if (fresh) next.push(fresh)
|
|
18
|
+
else {
|
|
19
|
+
stale.push(row)
|
|
20
|
+
if (!removeMissing) next.push(row)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const known = new Set(before.map((row) => row.id))
|
|
24
|
+
for (const row of advertised) {
|
|
25
|
+
if (!known.has(row.id)) next.push(row)
|
|
26
|
+
}
|
|
27
|
+
const diff = diffModels(before, next)
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
before,
|
|
31
|
+
advertised,
|
|
32
|
+
next,
|
|
33
|
+
stale,
|
|
34
|
+
diff,
|
|
35
|
+
changed: diff.hasChanges,
|
|
36
|
+
unchangedAdvertised: advertised.length === before.length && advertised.every((row, index) => sameModel(row, before[index])),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function catalogPatch(section, provider, nextModels) {
|
|
41
|
+
const current = section && typeof section === 'object' ? section : {}
|
|
42
|
+
const providers = current.providers && typeof current.providers === 'object'
|
|
43
|
+
? structuredClone(current.providers)
|
|
44
|
+
: {}
|
|
45
|
+
const profile = providers[provider] && typeof providers[provider] === 'object'
|
|
46
|
+
? providers[provider]
|
|
47
|
+
: {}
|
|
48
|
+
providers[provider] = { ...profile, models: structuredClone(nextModels) }
|
|
49
|
+
return { ...structuredClone(current), providers }
|
|
50
|
+
}
|
package/lib/scheduler.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function createSyncScheduler(sync, { getConfig = () => ({}), setIntervalFn = setInterval, clearIntervalFn = clearInterval, logger = console } = {}) {
|
|
2
|
+
let timer = null
|
|
3
|
+
let running = false
|
|
4
|
+
|
|
5
|
+
function stop() {
|
|
6
|
+
if (timer !== null) clearIntervalFn(timer)
|
|
7
|
+
timer = null
|
|
8
|
+
running = false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function start() {
|
|
12
|
+
stop()
|
|
13
|
+
const config = getConfig() ?? {}
|
|
14
|
+
const minutes = Number(config.intervalMinutes)
|
|
15
|
+
if (config.enabled === false || !Number.isFinite(minutes) || minutes <= 0) return false
|
|
16
|
+
const period = Math.max(1, minutes) * 60_000
|
|
17
|
+
timer = setIntervalFn(async () => {
|
|
18
|
+
if (running) return
|
|
19
|
+
running = true
|
|
20
|
+
try {
|
|
21
|
+
await sync.run({ dryRun: config.autoApply !== true })
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logger.warn?.(`[dsh-model-sync] scheduled run failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
24
|
+
} finally {
|
|
25
|
+
running = false
|
|
26
|
+
}
|
|
27
|
+
}, period)
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
start,
|
|
33
|
+
stop,
|
|
34
|
+
reconfigure: start,
|
|
35
|
+
status: () => ({ active: timer !== null, running }),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { listApiKeyProviders } from './inventory.js'
|
|
2
|
+
import { createProviderAdapterRegistry } from './adapter-registry.js'
|
|
3
|
+
import { createCredentialResolver } from './credentials.js'
|
|
4
|
+
import { reconcileModels, catalogPatch } from './reconcile.js'
|
|
5
|
+
|
|
6
|
+
const PIAI_NS = 'llm-pi-ai'
|
|
7
|
+
|
|
8
|
+
function settingsOf(ctx) {
|
|
9
|
+
try { return ctx.get('settings') } catch { return undefined }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function descriptorOf(settings, ns) {
|
|
13
|
+
try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
|
|
14
|
+
catch { return undefined }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function profileOf(settings, provider) {
|
|
18
|
+
try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
|
|
19
|
+
catch { return {} }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorMessage(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createModelSynchronizer(ctx, { getConfig = () => ({}), fetchImpl = fetch } = {}) {
|
|
27
|
+
const resolveCredential = createCredentialResolver(ctx)
|
|
28
|
+
const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl })
|
|
29
|
+
let running = null
|
|
30
|
+
let lastRun = null
|
|
31
|
+
|
|
32
|
+
function providers() {
|
|
33
|
+
return listApiKeyProviders(ctx.llm, settingsOf(ctx))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function discover(row, { signal } = {}) {
|
|
37
|
+
const settings = settingsOf(ctx)
|
|
38
|
+
const profile = { ...profileOf(settings, row.provider), provider: row.provider }
|
|
39
|
+
const adapter = registry.select(profile)
|
|
40
|
+
if (!adapter) return { provider: row.provider, status: 'unsupported', models: [], message: 'no supported API-key model adapter' }
|
|
41
|
+
if (!profile.apiKeyEnv && !profile.baseURL) {
|
|
42
|
+
return { provider: row.provider, status: 'dormant', models: [], message: 'provider has no API key route configured' }
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const models = await adapter.discover(profile, { signal })
|
|
46
|
+
return { provider: row.provider, status: 'ok', models }
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { provider: row.provider, status: 'error', models: [], message: errorMessage(error) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function run(options = {}) {
|
|
53
|
+
if (running) return running
|
|
54
|
+
const config = getConfig() ?? {}
|
|
55
|
+
const dryRun = options.dryRun !== false
|
|
56
|
+
const removeMissing = options.removeMissing === true
|
|
57
|
+
const selected = typeof options.provider === 'string' && options.provider
|
|
58
|
+
? providers().filter((row) => row.provider === options.provider)
|
|
59
|
+
: providers().filter((row) => config.providers?.length === 0 || config.providers?.some((item) => item.provider === row.provider && item.enabled !== false))
|
|
60
|
+
const startedAt = Date.now()
|
|
61
|
+
const task = (async () => {
|
|
62
|
+
const results = []
|
|
63
|
+
for (const row of selected) {
|
|
64
|
+
const discovered = await discover(row, options)
|
|
65
|
+
if (discovered.status !== 'ok') {
|
|
66
|
+
results.push({ ...discovered, changed: false })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const settings = settingsOf(ctx)
|
|
70
|
+
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 })
|
|
73
|
+
}
|
|
74
|
+
const result = { startedAt, finishedAt: Date.now(), dryRun, removeMissing, results, applied: false }
|
|
75
|
+
if (!dryRun && results.some((row) => row.status === 'ok' && row.changed)) {
|
|
76
|
+
const settings = settingsOf(ctx)
|
|
77
|
+
const section = settings?.get?.(PIAI_NS)
|
|
78
|
+
const descriptor = descriptorOf(settings, PIAI_NS)
|
|
79
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
80
|
+
throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
|
|
81
|
+
}
|
|
82
|
+
const next = structuredClone(section)
|
|
83
|
+
for (const row of results) {
|
|
84
|
+
if (row.status === 'ok' && row.changed) next.providers = catalogPatch(next, row.provider, row.next).providers
|
|
85
|
+
}
|
|
86
|
+
await settings.replace(PIAI_NS, next, descriptor.revision)
|
|
87
|
+
result.applied = true
|
|
88
|
+
}
|
|
89
|
+
lastRun = result
|
|
90
|
+
return result
|
|
91
|
+
})()
|
|
92
|
+
running = task.finally(() => { running = null })
|
|
93
|
+
return running
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
listProviders: providers,
|
|
98
|
+
getProvider: (provider) => providers().find((row) => row.provider === provider) ?? null,
|
|
99
|
+
discover,
|
|
100
|
+
run,
|
|
101
|
+
status: () => ({ running: Boolean(running), lastRun }),
|
|
102
|
+
})
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-model-sync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
|
|
5
|
+
"keywords": ["deepseek-harness", "dsh", "dsh-plugin", "models", "api-key", "catalog"],
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-model-sync.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-model-sync",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-model-sync/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./client": "./lib/client.js",
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
21
|
+
},
|
|
22
|
+
"files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
42
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
43
|
+
}
|
|
44
|
+
}
|