dsh-model-capability-sync 0.0.0-stage → 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/package.json CHANGED
@@ -1,6 +1,90 @@
1
1
  {
2
2
  "name": "dsh-model-capability-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Fill model capabilities (context window, output cap, input modalities, reasoning efforts) into an already-configured DeepSeek Harness LLM provider by matching gateway model ids against the models.dev catalog. Host half fetches and matches; browser half previews and writes through the official settings service.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./src/*": "./src/*",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "src",
16
+ "scripts",
17
+ "cordis.patch.yml",
18
+ "LICENSE",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "dsh": "^0.2.1-rc.2"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-locale",
32
+ "@deepseek-ai/dsh-client-ui-renderer",
33
+ "@deepseek-ai/dsh-client-ui-settings",
34
+ "@deepseek-ai/dsh-client-connection",
35
+ "@deepseek-ai/dsh-api-remotes"
36
+ ]
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "npm run build:host && npm run build:client",
41
+ "build:host": "node scripts/build-host.mjs",
42
+ "build:client": "node scripts/build-client.mjs",
43
+ "typecheck": "npm run typecheck:host && npm run typecheck:client",
44
+ "typecheck:host": "tsc -p tsconfig.json --noEmit",
45
+ "typecheck:client": "tsc -p tsconfig.client.json --noEmit",
46
+ "prepublishOnly": "npm run typecheck && npm run build && npm run verify:package",
47
+ "verify:package": "node scripts/verify-package.mjs",
48
+ "pack:check": "npm pack --dry-run"
49
+ },
50
+ "keywords": [
51
+ "dsh",
52
+ "dsh-plugin",
53
+ "deepseek",
54
+ "deepseek-harness",
55
+ "models.dev",
56
+ "capability",
57
+ "context-window",
58
+ "multimodal"
59
+ ],
60
+ "license": "MIT",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/zyi07/dsh-model-capability-sync.git"
64
+ },
65
+ "homepage": "https://github.com/zyi07/dsh-model-capability-sync#readme",
66
+ "bugs": {
67
+ "url": "https://github.com/zyi07/dsh-model-capability-sync/issues"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "peerDependencies": {
73
+ "@deepseek-ai/cordis": "^4.0.1",
74
+ "react": "^18.2.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@deepseek-ai/cordis": {
78
+ "optional": true
79
+ },
80
+ "react": {
81
+ "optional": true
82
+ }
83
+ },
84
+ "devDependencies": {
85
+ "@types/node": "^22.20.5",
86
+ "@types/react": "^18.2.0",
87
+ "esbuild": "^0.25.0",
88
+ "typescript": "^5.9.2"
89
+ }
90
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Browser-half build: one closure-factory module registered on the shell's
3
+ * module loader — `window.__ModuleLoader__.load({ id, factory })`. React and
4
+ * the JSX runtime are the only runtime externals the shell's module table
5
+ * supplies; every `@deepseek-ai/*` import in the source is type-only and is
6
+ * erased here.
7
+ */
8
+ import { build } from 'esbuild'
9
+
10
+ const ID = 'dsh-model-capability-sync'
11
+
12
+ const CLIENT_EXTERNALS = [
13
+ 'react',
14
+ 'react/jsx-runtime',
15
+ 'react-dom',
16
+ 'react-dom/client',
17
+ ]
18
+
19
+ await build({
20
+ entryPoints: ['src/client/index.ts'],
21
+ bundle: true,
22
+ format: 'cjs',
23
+ platform: 'browser',
24
+ target: 'es2022',
25
+ jsx: 'automatic',
26
+ outfile: 'lib/client.js',
27
+ sourcemap: true,
28
+ legalComments: 'none',
29
+ external: CLIENT_EXTERNALS,
30
+ define: {
31
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
32
+ },
33
+ banner: {
34
+ js: [
35
+ `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: (require) => {`,
36
+ 'var module = { exports: {} }; var exports = module.exports;',
37
+ ].join('\n'),
38
+ },
39
+ footer: { js: 'return module.exports; } });' },
40
+ })
41
+
42
+ console.log(`${ID}: wrote lib/client.js`)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Host-half build: bundle `src/index.ts` (and its relative modules) into one
3
+ * ESM file. Every bare specifier stays external, so the plugin resolves
4
+ * `@deepseek-ai/*` from the host installation at load time — the same contract
5
+ * the official adapters use.
6
+ */
7
+ import { build } from 'esbuild'
8
+
9
+ await build({
10
+ entryPoints: ['src/index.ts'],
11
+ bundle: true,
12
+ format: 'esm',
13
+ platform: 'node',
14
+ target: 'node22',
15
+ outfile: 'lib/index.js',
16
+ sourcemap: true,
17
+ legalComments: 'none',
18
+ packages: 'external',
19
+ logLevel: 'info',
20
+ })
21
+
22
+ console.log('dsh-model-capability-sync: wrote lib/index.js')
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Offline-ish smoke test: download the real models.dev catalog and run the
3
+ * matcher + mapper over the model ids this machine has configured.
4
+ *
5
+ * Run with `node --experimental-strip-types scripts/smoke.mjs` (Node 24 strips
6
+ * types by default). It reads ids from the desktop profile patch when present
7
+ * and falls back to a built-in sample, so it also works on a fresh checkout.
8
+ */
9
+ import { readFileSync, existsSync } from 'node:fs'
10
+ import { loadCatalog } from '../src/catalog.ts'
11
+ import { matchModel } from '../src/match.ts'
12
+ import { capabilitiesOf } from '../src/mapping.ts'
13
+
14
+ const PATCH = 'C:/Users/19603/.dsh/profiles/desktop/cordis.patch.yml'
15
+
16
+ /** Pull `- id:` lines out of the llm-newapi catalog block, without a YAML dependency. */
17
+ function idsFromPatch(path) {
18
+ if (!existsSync(path)) return undefined
19
+ const text = readFileSync(path, 'utf8')
20
+ const start = text.indexOf('- id: llm-newapi')
21
+ if (start === -1) return undefined
22
+ const block = text.slice(start)
23
+ const ids = [...block.matchAll(/^\s+- id: (\S+)$/gm)].map(match => match[1])
24
+ return ids.length > 0 ? ids : undefined
25
+ }
26
+
27
+ const ids = idsFromPatch(PATCH) ?? [
28
+ 'work-gj/deepseek-v4.1-flash',
29
+ 'gemini/gemini-3.8-flash-high',
30
+ 'qoder-gj/Qwen3.8-Flash',
31
+ 'beichenapi/gpt-6',
32
+ 'openai/gpt-4o',
33
+ ]
34
+
35
+ const catalog = await loadCatalog({ refresh: true })
36
+ console.log(`catalog: ${catalog.modelCount} models, fetched ${new Date(catalog.fetchedAt).toISOString()}`)
37
+
38
+ const stats = { total: 0, matched: 0, image: 0, unmatched: [] }
39
+ const sample = []
40
+ for (const id of ids) {
41
+ stats.total += 1
42
+ const match = matchModel(catalog, id)
43
+ if (match === undefined) {
44
+ stats.unmatched.push(id)
45
+ continue
46
+ }
47
+ stats.matched += 1
48
+ const entry = catalog.byId.get(match.modelId)?.find(candidate => candidate.provider === match.provider)
49
+ const fields = entry === undefined ? {} : capabilitiesOf(entry)
50
+ if (fields.input?.includes('image')) stats.image += 1
51
+ if (sample.length < 6) sample.push({ id, via: match.via, to: `${match.provider}/${match.modelId}`, fields })
52
+ }
53
+
54
+ console.log(`matched ${stats.matched}/${stats.total}, image-capable ${stats.image}, unmatched ${stats.unmatched.length}`)
55
+ console.log('sample:', JSON.stringify(sample, null, 1))
56
+ if (stats.unmatched.length > 0) console.log('unmatched ids:', stats.unmatched.join(', '))
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Release-hygiene check for the publishable surface.
3
+ *
4
+ * Runs against the source tree, not a tarball, so it is fast enough to keep in
5
+ * the release checklist. It catches the mistakes that only show up after a
6
+ * publish: a build artifact missing from the tree, a `dsh` declaration that
7
+ * points nowhere, a client bundle without the loader wrapper, a manifest field
8
+ * the host would reject.
9
+ *
10
+ * node scripts/verify-package.mjs
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ const root = process.cwd()
16
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
17
+ const checks = []
18
+ const check = (label, ok, detail = '') => {
19
+ checks.push({ label, ok, detail })
20
+ }
21
+
22
+ check('name 是合法 npm 包名', /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(manifest.name), manifest.name)
23
+ check('version 是精确 SemVer', /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/.test(manifest.version), manifest.version)
24
+ check('未声明 private', manifest.private !== true)
25
+ check('publishConfig.access 为 public', manifest.publishConfig?.access === 'public')
26
+ check('engines.dsh 已声明', typeof manifest.engines?.dsh === 'string', manifest.engines?.dsh)
27
+ check('main 指向存在的文件', typeof manifest.main === 'string' && existsSync(join(root, manifest.main)), manifest.main)
28
+ check('exports["./client"] 存在', existsSync(join(root, 'lib/client.js')))
29
+ check('LICENSE 文件存在', existsSync(join(root, 'LICENSE')))
30
+ check('README 存在', existsSync(join(root, 'README.md')))
31
+ check('repository.url 已声明', typeof manifest.repository?.url === 'string', manifest.repository?.url)
32
+ check('homepage 是绝对 URL', typeof manifest.homepage === 'string' && URL.canParse(manifest.homepage), manifest.homepage)
33
+ check('bugs.url 是绝对 URL', typeof manifest.bugs?.url === 'string' && URL.canParse(manifest.bugs.url), manifest.bugs?.url)
34
+
35
+ const patchPath = manifest.dsh?.bundle?.patch
36
+ check('dsh.bundle.patch 已声明', typeof patchPath === 'string', patchPath)
37
+ check('dsh.bundle.patch 文件存在', typeof patchPath === 'string' && existsSync(join(root, patchPath)))
38
+ check('dsh.client.platform 为 web', manifest.dsh?.client?.platform === 'web')
39
+
40
+ if (typeof patchPath === 'string' && existsSync(join(root, patchPath))) {
41
+ const patch = readFileSync(join(root, patchPath), 'utf8')
42
+ check('patch 里插入了本插件', patch.includes('model-capability-sync'))
43
+ }
44
+
45
+ if (existsSync(join(root, 'lib/client.js'))) {
46
+ const client = readFileSync(join(root, 'lib/client.js'), 'utf8')
47
+ check('client 带 __ModuleLoader__ 包装', client.includes('__ModuleLoader__'))
48
+ check('client 只外部化 react', !/require\("(?!react)/.test(client))
49
+ }
50
+
51
+ const hostBundle = join(root, manifest.main ?? '')
52
+ if (existsSync(hostBundle)) {
53
+ const host = readFileSync(hostBundle, 'utf8')
54
+ check('宿主产物不 import 第三方包', !/^import .* from "(?!node:|\.)/m.test(host))
55
+ }
56
+
57
+ for (const entry of manifest.files ?? []) {
58
+ check(`files 条目存在:${entry}`, existsSync(join(root, entry)))
59
+ }
60
+
61
+ const failed = checks.filter(entry => !entry.ok)
62
+ for (const entry of checks) console.log(`${entry.ok ? 'OK ' : 'FAIL'} ${entry.label}${entry.detail === '' ? '' : ` (${entry.detail})`}`)
63
+ console.log(failed.length === 0 ? `\n全部通过:${String(checks.length)} 项` : `\n失败 ${String(failed.length)} 项`)
64
+ process.exitCode = failed.length === 0 ? 0 : 1
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Warm the on-disk catalog copy.
3
+ *
4
+ * The host process may sit behind a network the command line does not share,
5
+ * so the page falls back to `~/.dsh/model-capability-sync/catalog.json`. This
6
+ * script fills that file from a machine that can reach models.dev, which makes
7
+ * the first page load work even when the app itself cannot download.
8
+ *
9
+ * node --experimental-strip-types scripts/warm-cache.mjs
10
+ */
11
+ import { loadCatalog } from '../src/catalog.ts'
12
+
13
+ const catalog = await loadCatalog({ refresh: true })
14
+ console.log(`cached ${String(catalog.modelCount)} models from ${new Date(catalog.fetchedAt).toISOString()}`)
15
+ console.log('file: ~/.dsh/model-capability-sync/catalog.json')
package/src/catalog.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The models.dev catalog: download, cache, and index for lookup.
3
+ *
4
+ * One shared `api.json` carries every provider's model facts. The download
5
+ * happens host-side so the browser never makes a cross-origin request, and a
6
+ * forward proxy stays a plain HTTP proxy the host can use.
7
+ *
8
+ * A copy also lands on disk. The host process may sit behind a network the
9
+ * command line does not share — a proxy, a firewall, a flaky route — and a
10
+ * settings page that cannot open because one download failed is worse than a
11
+ * page reading a slightly old catalog. The disk copy is the fallback; its age
12
+ * is reported so the answer is never silently stale.
13
+ * @module dsh-model-capability-sync/catalog
14
+ */
15
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ const CATALOG_URL = 'https://models.dev/api.json'
20
+ /** Reuse one download for this long; a `refresh` request bypasses it. */
21
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000
22
+ /** Attempts per download before falling back to the disk copy. */
23
+ const DOWNLOAD_ATTEMPTS = 3
24
+ /** Pause between attempts: long enough to outlast a reset connection. */
25
+ const RETRY_DELAY_MS = 600
26
+
27
+ /** One model entry as models.dev publishes it (only the fields we read). */
28
+ export interface ModelsDevModel {
29
+ id?: string
30
+ name?: string
31
+ description?: string
32
+ /** Whether the model accepts reasoning control at all. */
33
+ reasoning?: boolean
34
+ /** Accepted input/output media; `input` is the modality list we map. */
35
+ modalities?: { input?: string[]; output?: string[] }
36
+ /** Capacity facts: token counts. */
37
+ limit?: { context?: number; input?: number; output?: number }
38
+ /** Reasoning control shapes; the `effort` type carries the selectable levels. */
39
+ reasoning_options?: Array<{ type?: string; values?: Array<string | null> }>
40
+ }
41
+
42
+ /** One catalog entry with its provider id attached. */
43
+ export interface CatalogEntry {
44
+ provider: string
45
+ id: string
46
+ model: ModelsDevModel
47
+ }
48
+
49
+ /** A loaded catalog plus its lookup indexes. */
50
+ export interface Catalog {
51
+ fetchedAt: number
52
+ modelCount: number
53
+ /** True when this copy came from disk because the download failed. */
54
+ stale?: boolean
55
+ /** Exact id → entries (a bare id can appear under several providers). */
56
+ byId: Map<string, CatalogEntry[]>
57
+ /** Lower-cased id → entries, for case-insensitive vendor spellings. */
58
+ byFolded: Map<string, CatalogEntry[]>
59
+ }
60
+
61
+ let cache: { catalog: Catalog; proxyUrl: string | undefined } | undefined
62
+
63
+ /** Where the on-disk copy lives, mirroring the other plugins' data roots. */
64
+ const CACHE_FILE = join(homedir(), '.dsh', 'model-capability-sync', 'catalog.json')
65
+
66
+ /** The on-disk envelope: the raw catalog body plus when it was fetched. */
67
+ interface DiskCache {
68
+ fetchedAt: number
69
+ source: string
70
+ body: unknown
71
+ }
72
+
73
+ /** Persist one successful download; a cache write must never fail the request. */
74
+ async function writeDiskCache(body: unknown): Promise<void> {
75
+ try {
76
+ const envelope: DiskCache = { fetchedAt: Date.now(), source: CATALOG_URL, body }
77
+ await mkdir(dirname(CACHE_FILE), { recursive: true })
78
+ await writeFile(CACHE_FILE, JSON.stringify(envelope), 'utf8')
79
+ } catch {
80
+ // A read-only or full home directory only costs the fallback, not the answer.
81
+ }
82
+ }
83
+
84
+ /** Read the on-disk copy, or undefined when there is none to trust. */
85
+ async function readDiskCache(): Promise<DiskCache | undefined> {
86
+ try {
87
+ const parsed = JSON.parse(await readFile(CACHE_FILE, 'utf8')) as Partial<DiskCache>
88
+ if (typeof parsed.fetchedAt !== 'number' || parsed.body === undefined) return undefined
89
+ return { fetchedAt: parsed.fetchedAt, source: typeof parsed.source === 'string' ? parsed.source : CATALOG_URL, body: parsed.body }
90
+ } catch {
91
+ return undefined
92
+ }
93
+ }
94
+
95
+ /** Wait between download attempts. */
96
+ function pause(ms: number): Promise<void> {
97
+ return new Promise((resolve) => { setTimeout(resolve, ms) })
98
+ }
99
+
100
+ /** Push one entry into an index bucket. */
101
+ function push(index: Map<string, CatalogEntry[]>, key: string, entry: CatalogEntry): void {
102
+ const bucket = index.get(key)
103
+ if (bucket === undefined) index.set(key, [entry])
104
+ else bucket.push(entry)
105
+ }
106
+
107
+ /** Build both indexes from the raw catalog body. */
108
+ function buildCatalog(body: unknown, fetchedAt: number): Catalog {
109
+ const byId = new Map<string, CatalogEntry[]>()
110
+ const byFolded = new Map<string, CatalogEntry[]>()
111
+ let modelCount = 0
112
+ if (typeof body === 'object' && body !== null) {
113
+ for (const [provider, value] of Object.entries(body as Record<string, unknown>)) {
114
+ const models = (value as { models?: Record<string, ModelsDevModel> } | undefined)?.models
115
+ if (typeof models !== 'object' || models === null) continue
116
+ for (const [id, model] of Object.entries(models)) {
117
+ if (id.length === 0) continue
118
+ modelCount += 1
119
+ const entry: CatalogEntry = { provider, id, model }
120
+ push(byId, id, entry)
121
+ push(byFolded, id.toLowerCase(), entry)
122
+ }
123
+ }
124
+ }
125
+ if (modelCount === 0) throw new Error('models.dev returned a catalog with no models')
126
+ return { fetchedAt, modelCount, byId, byFolded }
127
+ }
128
+
129
+ /** Download the catalog body, optionally through a forward proxy. */
130
+ async function download(proxyUrl: string | undefined, signal: AbortSignal | undefined): Promise<unknown> {
131
+ if (proxyUrl === undefined || proxyUrl.length === 0) {
132
+ const response = await fetch(CATALOG_URL, { signal })
133
+ if (!response.ok) throw new Error(`models.dev download failed (HTTP ${String(response.status)})`)
134
+ return await response.json()
135
+ }
136
+ // The proxy path needs undici's dispatcher. It ships with the harness
137
+ // runtime; a missing copy is reported instead of silently going direct. The
138
+ // specifier stays a runtime value so this package needs no undici types.
139
+ interface UndiciFace {
140
+ fetch(input: string, init?: Record<string, unknown>): Promise<Response>
141
+ ProxyAgent: new (url: string) => unknown
142
+ }
143
+ const specifier: string = 'undici'
144
+ let undici: UndiciFace
145
+ try {
146
+ undici = await import(specifier) as UndiciFace
147
+ } catch {
148
+ throw new Error('a proxy URL was set but the host cannot load "undici"; clear the proxy or install it')
149
+ }
150
+ const dispatcher = new undici.ProxyAgent(proxyUrl)
151
+ const response = await undici.fetch(CATALOG_URL, { signal, dispatcher })
152
+ if (!response.ok) throw new Error(`models.dev download through the proxy failed (HTTP ${String(response.status)})`)
153
+ return await response.json()
154
+ }
155
+
156
+ /**
157
+ * Load the catalog, reusing the cached copy unless it expired or the caller
158
+ * asked for a refresh.
159
+ *
160
+ * A failed download is retried, then answered from the on-disk copy with
161
+ * `stale` set. Only when both paths fail does this throw, and the message
162
+ * names the network reason so the page can show something actionable.
163
+ * @param options - refresh flag and optional proxy URL.
164
+ * @param signal - caller cancellation.
165
+ * @returns the indexed catalog.
166
+ */
167
+ export async function loadCatalog(
168
+ options: { refresh?: boolean; proxyUrl?: string } = {},
169
+ signal?: AbortSignal,
170
+ ): Promise<Catalog> {
171
+ const now = Date.now()
172
+ const fresh = cache !== undefined
173
+ && cache.proxyUrl === options.proxyUrl
174
+ && now - cache.catalog.fetchedAt < CACHE_TTL_MS
175
+ if (options.refresh !== true && fresh && cache !== undefined) return cache.catalog
176
+
177
+ let lastError: unknown
178
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
179
+ try {
180
+ const body = await download(options.proxyUrl, signal)
181
+ const catalog = buildCatalog(body, Date.now())
182
+ cache = { catalog, proxyUrl: options.proxyUrl }
183
+ void writeDiskCache(body)
184
+ return catalog
185
+ } catch (error) {
186
+ if (signal?.aborted === true) throw error
187
+ lastError = error
188
+ if (attempt < DOWNLOAD_ATTEMPTS) await pause(RETRY_DELAY_MS)
189
+ }
190
+ }
191
+
192
+ const disk = await readDiskCache()
193
+ if (disk !== undefined) {
194
+ try {
195
+ const catalog: Catalog = { ...buildCatalog(disk.body, disk.fetchedAt), stale: true }
196
+ cache = { catalog, proxyUrl: options.proxyUrl }
197
+ return catalog
198
+ } catch {
199
+ // A corrupt copy is no better than no copy; report the download failure.
200
+ }
201
+ }
202
+ const reason = lastError instanceof Error ? lastError.message : String(lastError)
203
+ throw new Error(`models.dev catalog unavailable (${reason}) and no local copy exists`)
204
+ }