@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 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
+ ```
@@ -0,0 +1,4 @@
1
+ # Bundle entry for the public DSH model synchronization plugin.
2
+ - insert:
3
+ - id: dsh-model-sync
4
+ name: '@goodandready/dsh-model-sync'
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }