@fonlan/dsh-model-parameters 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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * The Model Parameters settings page: master + per-field toggles, catalog TTL,
3
+ * provider→models.dev mapping overrides, a manual refresh/fill action, the
4
+ * catalog freshness card, and the last fill report (filled / touched /
5
+ * unmatched). All reads/mutations go through the plugin's fenced API; config
6
+ * persists into the `model-parameters` settings namespace (settings.yaml).
7
+ */
8
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
9
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
10
+ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import { LOCALE_NS } from './locales'
12
+ import { api, type PluginState } from './api'
13
+ import type { ModelParametersConfig } from '../shared/config.js'
14
+ import './settings-section.css'
15
+
16
+ function useLocaleRevision(ctx: ClientContext): number {
17
+ const subscribe = useCallback(
18
+ (onChange: () => void) => {
19
+ try {
20
+ return ctx.locale.subscribe(onChange)
21
+ } catch {
22
+ return () => {}
23
+ }
24
+ },
25
+ [ctx],
26
+ )
27
+ const getSnapshot = useCallback(() => {
28
+ try {
29
+ return ctx.locale.getLocale().revision
30
+ } catch {
31
+ return 0
32
+ }
33
+ }, [ctx])
34
+ return useSyncExternalStore(subscribe, getSnapshot)
35
+ }
36
+
37
+ function formatTime(epochMs: number): string {
38
+ try {
39
+ return new Date(epochMs).toLocaleString()
40
+ } catch {
41
+ return String(epochMs)
42
+ }
43
+ }
44
+
45
+ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
46
+ const t: Translate = (() => {
47
+ try {
48
+ return ctx.locale.bind(LOCALE_NS) as unknown as Translate
49
+ } catch {
50
+ return (key: string) => key
51
+ }
52
+ })()
53
+
54
+ return function ModelParametersSettingsSection(): JSX.Element {
55
+ useLocaleRevision(ctx)
56
+ const [state, setState] = useState<PluginState | null>(null)
57
+ const [error, setError] = useState<string | null>(null)
58
+ const [loading, setLoading] = useState(true)
59
+ const [busy, setBusy] = useState(false)
60
+ const [saving, setSaving] = useState(false)
61
+ const [mappingRows, setMappingRows] = useState<Array<{ from: string; to: string }>>([])
62
+ const [newFrom, setNewFrom] = useState('')
63
+ const [newTo, setNewTo] = useState('')
64
+
65
+ const load = useCallback(async () => {
66
+ setLoading(true)
67
+ try {
68
+ const next = await api.state()
69
+ setState(next)
70
+ setMappingRows(Object.entries(next.config.providerMap).map(([from, to]) => ({ from, to })))
71
+ setError(null)
72
+ } catch (cause) {
73
+ setError(cause instanceof Error ? cause.message : String(cause))
74
+ } finally {
75
+ setLoading(false)
76
+ }
77
+ }, [])
78
+
79
+ useEffect(() => {
80
+ void load()
81
+ }, [load])
82
+
83
+ const patchConfig = useCallback(async (patch: Partial<ModelParametersConfig>) => {
84
+ setSaving(true)
85
+ try {
86
+ const next = await api.setConfig(patch)
87
+ setState(next)
88
+ setMappingRows(Object.entries(next.config.providerMap).map(([from, to]) => ({ from, to })))
89
+ setError(null)
90
+ } catch (cause) {
91
+ setError(cause instanceof Error ? cause.message : String(cause))
92
+ } finally {
93
+ setSaving(false)
94
+ }
95
+ }, [])
96
+
97
+ const refresh = useCallback(async () => {
98
+ setBusy(true)
99
+ try {
100
+ setState(await api.refresh())
101
+ setError(null)
102
+ } catch (cause) {
103
+ setError(cause instanceof Error ? cause.message : String(cause))
104
+ } finally {
105
+ setBusy(false)
106
+ }
107
+ }, [])
108
+
109
+ const reconcileNow = useCallback(async () => {
110
+ setBusy(true)
111
+ try {
112
+ setState(await api.reconcile())
113
+ setError(null)
114
+ } catch (cause) {
115
+ setError(cause instanceof Error ? cause.message : String(cause))
116
+ } finally {
117
+ setBusy(false)
118
+ }
119
+ }, [])
120
+
121
+ const applyMappings = useCallback(async () => {
122
+ const providerMap: Record<string, string> = {}
123
+ for (const row of mappingRows) {
124
+ const from = row.from.trim()
125
+ const to = row.to.trim()
126
+ if (from.length > 0 && to.length > 0) providerMap[from] = to
127
+ }
128
+ await patchConfig({ providerMap })
129
+ }, [mappingRows, patchConfig])
130
+
131
+ if (error !== null && state === null) {
132
+ return (
133
+ <p className="mp-error">{t('error')}: {error}</p>
134
+ )
135
+ }
136
+ if (loading || state === null) {
137
+ return <p className="mp-muted">{t('saving')}</p>
138
+ }
139
+
140
+ const config = state.config
141
+ const catalog = state.catalog
142
+ const report = state.report
143
+
144
+ return (
145
+ <div className="mp-root">
146
+ <p className="mp-desc">{t('description')}</p>
147
+
148
+ <section className="mp-card">
149
+ <h4 className="mp-card-title">{t('fields')}</h4>
150
+ <label className="mp-toggle">
151
+ <input
152
+ type="checkbox"
153
+ checked={config.enabled}
154
+ disabled={saving}
155
+ onChange={(event) => void patchConfig({ enabled: event.target.checked })}
156
+ />
157
+ <span className="mp-toggle-body">
158
+ <span className="mp-toggle-label">{t('master')}</span>
159
+ <span className="mp-toggle-desc">{t('masterDesc')}</span>
160
+ </span>
161
+ </label>
162
+ {([
163
+ ['fillName', t('fillName'), t('fillNameDesc')],
164
+ ['fillContext', t('fillContext'), t('fillContextDesc')],
165
+ ['fillMaxTokens', t('fillMaxTokens'), t('fillMaxTokensDesc')],
166
+ ['fillReasoning', t('fillReasoning'), t('fillReasoningDesc')],
167
+ ['fillInput', t('fillInput'), t('fillInputDesc')],
168
+ ] as const).map(([key, label, desc]) => (
169
+ <label className="mp-toggle mp-toggle-sub" key={key}>
170
+ <input
171
+ type="checkbox"
172
+ checked={config[key]}
173
+ disabled={saving || !config.enabled}
174
+ onChange={(event) => void patchConfig({ [key]: event.target.checked })}
175
+ />
176
+ <span className="mp-toggle-body">
177
+ <span className="mp-toggle-label">{label}</span>
178
+ <span className="mp-toggle-desc">{desc}</span>
179
+ </span>
180
+ </label>
181
+ ))}
182
+ </section>
183
+
184
+ <section className="mp-card">
185
+ <h4 className="mp-card-title">{t('catalog')}</h4>
186
+ <div className="mp-meta">
187
+ <span>{t('catalogEntries')}: <strong>{catalog.entries.toLocaleString()}</strong></span>
188
+ <span>{t('catalogProviders')}: <strong>{catalog.providers.toLocaleString()}</strong></span>
189
+ <span>
190
+ {t('catalogFetched')}:{' '}
191
+ <strong>{catalog.fetchedAt > 0 ? formatTime(catalog.fetchedAt) : t('catalogNever')}</strong>{' '}
192
+ {catalog.fetchedAt > 0 && (
193
+ <span className={catalog.fresh ? 'mp-badge mp-badge-ok' : 'mp-badge mp-badge-warn'}>
194
+ {catalog.fresh ? t('catalogFresh') : t('catalogStale')}
195
+ </span>
196
+ )}
197
+ </span>
198
+ <label className="mp-ttl">
199
+ {t('ttl')}
200
+ <input
201
+ type="number"
202
+ min={1}
203
+ max={90}
204
+ value={config.ttlDays}
205
+ disabled={saving}
206
+ onChange={(event) => {
207
+ const days = Number(event.target.value)
208
+ if (Number.isFinite(days) && days >= 1 && days <= 90) void patchConfig({ ttlDays: Math.round(days) })
209
+ }}
210
+ />
211
+ </label>
212
+ </div>
213
+ <div className="mp-actions">
214
+ <button className="mp-button" disabled={busy} onClick={() => void refresh()}>
215
+ {busy ? t('refreshBusy') : t('refresh')}
216
+ </button>
217
+ <button className="mp-button mp-button-ghost" disabled={busy} onClick={() => void reconcileNow()}>
218
+ {t('reconcileNow')}
219
+ </button>
220
+ </div>
221
+ </section>
222
+
223
+ <section className="mp-card">
224
+ <h4 className="mp-card-title">{t('providerMap')}</h4>
225
+ <p className="mp-toggle-desc">{t('providerMapDesc')}</p>
226
+ {mappingRows.map((row, index) => (
227
+ <div className="mp-map-row" key={index}>
228
+ <input
229
+ value={row.from}
230
+ disabled={saving}
231
+ onChange={(event) => {
232
+ const next = [...mappingRows]
233
+ next[index] = { ...row, from: event.target.value }
234
+ setMappingRows(next)
235
+ }}
236
+ />
237
+ <span className="mp-map-arrow">→</span>
238
+ <input
239
+ value={row.to}
240
+ disabled={saving}
241
+ onChange={(event) => {
242
+ const next = [...mappingRows]
243
+ next[index] = { ...row, to: event.target.value }
244
+ setMappingRows(next)
245
+ }}
246
+ />
247
+ <button
248
+ className="mp-button mp-button-ghost"
249
+ disabled={saving}
250
+ onClick={() => setMappingRows(mappingRows.filter((_, i) => i !== index))}
251
+ >
252
+ {t('removeMapping')}
253
+ </button>
254
+ </div>
255
+ ))}
256
+ <div className="mp-map-row">
257
+ <input
258
+ placeholder="dsh provider"
259
+ value={newFrom}
260
+ onChange={(event) => setNewFrom(event.target.value)}
261
+ />
262
+ <span className="mp-map-arrow">→</span>
263
+ <input
264
+ placeholder="models.dev provider"
265
+ value={newTo}
266
+ onChange={(event) => setNewTo(event.target.value)}
267
+ />
268
+ <button
269
+ className="mp-button"
270
+ disabled={newFrom.trim().length === 0 || newTo.trim().length === 0}
271
+ onClick={() => {
272
+ setMappingRows([...mappingRows, { from: newFrom.trim(), to: newTo.trim() }])
273
+ setNewFrom('')
274
+ setNewTo('')
275
+ }}
276
+ >
277
+ {t('addMapping')}
278
+ </button>
279
+ </div>
280
+ <div className="mp-actions">
281
+ <button className="mp-button mp-button-ghost" disabled={saving} onClick={() => void applyMappings()}>
282
+ {saving ? t('saving') : '✓ ' + t('providerMap')}
283
+ </button>
284
+ </div>
285
+ </section>
286
+
287
+ <section className="mp-card">
288
+ <h4 className="mp-card-title">{t('report')}</h4>
289
+ {report === null ? (
290
+ <p className="mp-muted">{t('reportNever')}</p>
291
+ ) : (
292
+ <ul className="mp-report">
293
+ <li>{t('reportAt')}: {formatTime(report.at)}</li>
294
+ <li>{t('reportFilled')}: <strong>{report.filled}</strong></li>
295
+ <li>{t('reportTouched')}: <strong>{report.touched}</strong></li>
296
+ <li>
297
+ {t('reportUnmatched')}:{' '}
298
+ {report.unmatched.length === 0
299
+ ? t('reportEmpty')
300
+ : <code className="mp-code">{report.unmatched.join(', ')}</code>}
301
+ </li>
302
+ </ul>
303
+ )}
304
+ </section>
305
+
306
+ {error !== null && <p className="mp-error">{error}</p>}
307
+ </div>
308
+ )
309
+ }
310
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Declare this plugin's locale namespace so slot registrations can type-check
3
+ * the `locale` seat. zh is the key source of truth; en must carry the exact
4
+ * same key set (checked at runtime by the locale registry).
5
+ */
6
+ import type { zh } from './locales'
7
+
8
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
9
+ interface LocaleNamespaceMap {
10
+ 'model-parameters': keyof typeof zh
11
+ }
12
+ }
13
+
14
+ export {}
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @fonlan/dsh-model-parameters host half.
3
+ *
4
+ * Watches the `llm-pi-ai` settings namespace: whenever provider models are
5
+ * written there with missing display name / context window / max output
6
+ * tokens / reasoning efforts / input modalities (the norm for a freshly
7
+ * synced gateway), it fills the missing fields from a cached models.dev
8
+ * catalog (TTL-refreshed), and serves the fenced JSON API the web settings
9
+ * page calls. The plugin's own toggles live in the `model-parameters`
10
+ * settings namespace (settings.yaml).
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { Config } from './shared/config.js'
14
+ import { ModelParametersService } from './server/service.js'
15
+ import { registerApiRoutes } from './server/rpc.js'
16
+ import {
17
+ CatalogManager,
18
+ acceptedInputModalities,
19
+ identityReasoningEfforts,
20
+ type CatalogEntry,
21
+ type CatalogSnapshot,
22
+ } from './server/catalog.js'
23
+
24
+ export const name = '@fonlan/dsh-model-parameters'
25
+
26
+ // Nothing is hard-required at apply time: the settings service is attached
27
+ // opportunistically (web, CLI, headless all work — headless profiles just
28
+ // expose no settings page), and the web server only exists in web profiles.
29
+ export const inject: string[] = []
30
+
31
+ export { Config }
32
+ export { CatalogManager, acceptedInputModalities, identityReasoningEfforts }
33
+ export type { CatalogEntry, CatalogSnapshot }
34
+
35
+ export function apply(ctx: Context): void {
36
+ const service = new ModelParametersService(ctx)
37
+
38
+ ctx.effect(() => {
39
+ service.start()
40
+ return () => service.stop()
41
+ }, 'model-parameters: service')
42
+
43
+ // The settings-page API only exists where a web server does.
44
+ ctx.inject(['webServer'], (sctx) => {
45
+ sctx.effect(() => registerApiRoutes(sctx, service), 'model-parameters: api routes')
46
+ })
47
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * models.dev catalog: fetch, disk cache, lazy TTL refresh, and id matching.
3
+ *
4
+ * The catalog is flattened into a list of entries keyed by provider, then
5
+ * indexed by the last path segment of the model id (case-insensitive) so a
6
+ * synced bare id like `qwen3.7-max` can resolve `Qwen/Qwen3.7-Max` wherever
7
+ * models.dev carries it.
8
+ *
9
+ * Collisions are real: the same bare id appears under dozens of providers with
10
+ * different limits. Resolution is deterministic and provider-aware:
11
+ * 1. the models.dev provider mapped for the dsh provider (providerMap
12
+ * override, then same provider id);
13
+ * 2. an exact full-id match (the dsh model id already carries a vendor/);
14
+ * 3. first-party vendor list position (officialProviders);
15
+ * 4. entry completeness (both context and output limits known);
16
+ * 5. alphabetical provider id (stable last resort).
17
+ */
18
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import {
22
+ ACCEPTED_INPUT_MODALITIES,
23
+ CATALOG_URL,
24
+ REASONING_LEVELS,
25
+ type ModelParametersConfig,
26
+ } from '../shared/config.js'
27
+
28
+ /** Structural logger subset satisfied by cordis' LoggerService. */
29
+ export interface CatalogLogger {
30
+ info(format: any, ...param: any[]): void
31
+ warn(format: any, ...param: any[]): void
32
+ }
33
+
34
+ /** One flattened models.dev entry. */
35
+ export interface CatalogEntry {
36
+ /** models.dev provider id that carries this model. */
37
+ provider: string
38
+ /** Full model id as models.dev keys it (may carry a vendor/ prefix). */
39
+ id: string
40
+ /** Display name. */
41
+ name?: string
42
+ /** Context window in tokens, when known. */
43
+ context?: number
44
+ /** Output limit in tokens, when known. */
45
+ output?: number
46
+ /** Whether the model reasons at all, when stated. */
47
+ reasoning?: boolean
48
+ /** Offered reasoning effort levels (e.g. ["low","high","max"]). */
49
+ effortValues?: string[]
50
+ /** Accepted request modalities, raw from models.dev (may include pdf/audio/video). */
51
+ input?: string[]
52
+ }
53
+
54
+ export interface CatalogSnapshot {
55
+ /** Flattened entries in stable provider order. */
56
+ entries: CatalogEntry[]
57
+ /** Number of models.dev providers covered. */
58
+ providers: number
59
+ /** Epoch ms the cache was last fetched (0 = never fetched). */
60
+ fetchedAt: number
61
+ }
62
+
63
+ const DAY_MS = 24 * 60 * 60 * 1000
64
+ const FETCH_TIMEOUT_MS = 20_000
65
+
66
+ function strippedId(id: string): string {
67
+ const last = id.split('/').pop() ?? id
68
+ return last.toLowerCase()
69
+ }
70
+
71
+ function isOfficial(provider: string, official: readonly string[]): number {
72
+ const index = official.indexOf(provider)
73
+ return index === -1 ? official.length : index
74
+ }
75
+
76
+ function completeness(entry: CatalogEntry): number {
77
+ return entry.context !== undefined && entry.output !== undefined ? 0 : 1
78
+ }
79
+
80
+ /** The cached raw document on disk: the api.json body plus fetch metadata. */
81
+ interface CacheDocument {
82
+ fetchedAt: number
83
+ body: Record<string, unknown>
84
+ }
85
+
86
+ export class CatalogManager {
87
+ private snapshot: CatalogSnapshot = { entries: [], providers: 0, fetchedAt: 0 }
88
+ private loaded = false
89
+ private inflight: Promise<void> | null = null
90
+ private readonly dir: string
91
+ private readonly cachePath: string
92
+
93
+ constructor(
94
+ private readonly logger: CatalogLogger,
95
+ dataDir?: string,
96
+ ) {
97
+ this.dir = dataDir ?? join(homedir(), '.dsh', 'model-parameters')
98
+ this.cachePath = join(this.dir, 'catalog.json')
99
+ }
100
+
101
+ /** The current snapshot (empty until the first load/fetch completes). */
102
+ snapshotOf(): CatalogSnapshot {
103
+ return this.snapshot
104
+ }
105
+
106
+ /**
107
+ * Load the disk cache once, then fetch when stale (or `force`). A failed
108
+ * fetch keeps the last good cache; with no cache at all the snapshot stays
109
+ * empty and the caller simply fills nothing. Concurrent callers share one
110
+ * in-flight refresh.
111
+ * @returns true when the snapshot may have changed.
112
+ */
113
+ async ensureFresh(config: Pick<ModelParametersConfig, 'ttlDays'>, force = false): Promise<boolean> {
114
+ if (!this.loaded) {
115
+ await this.loadCache()
116
+ this.loaded = true
117
+ }
118
+ const stale = Date.now() - this.snapshot.fetchedAt >= config.ttlDays * DAY_MS
119
+ if (!force && !stale) return false
120
+ if (this.inflight !== null) {
121
+ await this.inflight
122
+ return true
123
+ }
124
+ this.inflight = this.fetchAndPersist()
125
+ .catch((error) => {
126
+ this.logger.warn(
127
+ `model-parameters: catalog fetch failed (${error instanceof Error ? error.message : String(error)}); using cached copy`,
128
+ )
129
+ })
130
+ .finally(() => {
131
+ this.inflight = null
132
+ })
133
+ await this.inflight
134
+ return true
135
+ }
136
+
137
+ /** Force a fetch now; never throws (falls back to cache or keeps empty). */
138
+ async refresh(ttlDays: number): Promise<boolean> {
139
+ return this.ensureFresh({ ttlDays }, true)
140
+ }
141
+
142
+ /**
143
+ * Resolve one dsh model id to the best catalog entry for its provider.
144
+ * @param modelId - the model id stored in the dsh provider profile.
145
+ * @param dshProvider - the dsh provider route id.
146
+ * @param config - plugin config (providerMap + officialProviders).
147
+ */
148
+ resolve(modelId: string, dshProvider: string, config: Pick<ModelParametersConfig, 'providerMap' | 'officialProviders'>): CatalogEntry | undefined {
149
+ const mapped = config.providerMap[dshProvider]
150
+ const candidates = this.candidatesFor(modelId)
151
+ if (candidates.length === 0) return undefined
152
+ const official = config.officialProviders
153
+
154
+ const score = (entry: CatalogEntry): number[] => {
155
+ const providerMatch = entry.provider === mapped || entry.provider === dshProvider ? 0 : 1
156
+ const exact = modelId.includes('/') && strippedId(entry.id) === modelId.toLowerCase() ? 0 : 1
157
+ return [providerMatch, exact, isOfficial(entry.provider, official), completeness(entry)]
158
+ }
159
+
160
+ // Stable total order: score tuple, then provider id, then full id.
161
+ candidates.sort((a, b) => {
162
+ const sa = score(a)
163
+ const sb = score(b)
164
+ for (let i = 0; i < sa.length; i++) {
165
+ if (sa[i] !== sb[i]) return sa[i] - sb[i]
166
+ }
167
+ if (a.provider !== b.provider) return a.provider < b.provider ? -1 : 1
168
+ return a.id < b.id ? -1 : 1
169
+ })
170
+ return candidates[0]
171
+ }
172
+
173
+ /**
174
+ * All entries whose stripped id matches, plus any whose full id matches
175
+ * exactly (a dsh model id may already carry a vendor/ prefix that models.dev
176
+ * keys verbatim).
177
+ */
178
+ private candidatesFor(modelId: string): CatalogEntry[] {
179
+ const stripped = strippedId(modelId)
180
+ const out: CatalogEntry[] = []
181
+ for (const entry of this.snapshot.entries) {
182
+ if (strippedId(entry.id) === stripped) out.push(entry)
183
+ else if (modelId.includes('/') && entry.id.toLowerCase() === modelId.toLowerCase()) out.push(entry)
184
+ }
185
+ return out
186
+ }
187
+
188
+ // ── disk / wire ────────────────────────────────────────────────────────────
189
+
190
+ private async loadCache(): Promise<void> {
191
+ try {
192
+ const raw = await readFile(this.cachePath, 'utf8')
193
+ const doc = JSON.parse(raw) as CacheDocument
194
+ if (typeof doc.fetchedAt !== 'number' || typeof doc.body !== 'object' || doc.body === null) {
195
+ this.logger.warn('model-parameters: catalog cache is malformed; ignoring')
196
+ return
197
+ }
198
+ this.snapshot = this.buildSnapshot(doc.body, doc.fetchedAt)
199
+ this.logger.info(
200
+ `model-parameters: loaded ${this.snapshot.entries.length} catalog entries from cache (fetched ${new Date(doc.fetchedAt).toISOString()})`,
201
+ )
202
+ } catch (error) {
203
+ const code = (error as NodeJS.ErrnoException).code
204
+ if (code !== 'ENOENT') {
205
+ this.logger.warn(`model-parameters: catalog cache read failed: ${error instanceof Error ? error.message : String(error)}`)
206
+ }
207
+ }
208
+ }
209
+
210
+ private async fetchAndPersist(): Promise<void> {
211
+ const controller = new AbortController()
212
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
213
+ let response: Response
214
+ try {
215
+ response = await fetch(CATALOG_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
216
+ } finally {
217
+ clearTimeout(timer)
218
+ }
219
+ if (!response.ok) {
220
+ throw new Error(`HTTP ${response.status} ${response.statusText}`)
221
+ }
222
+ const body = (await response.json()) as Record<string, unknown>
223
+ const fetchedAt = Date.now()
224
+ this.snapshot = this.buildSnapshot(body, fetchedAt)
225
+ await mkdir(this.dir, { recursive: true })
226
+ const doc: CacheDocument = { fetchedAt, body }
227
+ await writeFile(this.cachePath, JSON.stringify(doc), 'utf8')
228
+ this.logger.info(
229
+ `model-parameters: catalog refreshed from models.dev (${this.snapshot.providers} providers, ${this.snapshot.entries.length} models)`,
230
+ )
231
+ }
232
+
233
+ private buildSnapshot(body: Record<string, unknown>, fetchedAt: number): CatalogSnapshot {
234
+ const entries: CatalogEntry[] = []
235
+ for (const [provider, raw] of Object.entries(body)) {
236
+ if (typeof raw !== 'object' || raw === null) continue
237
+ const providerDoc = raw as Record<string, unknown>
238
+ const models = providerDoc.models
239
+ if (typeof models !== 'object' || models === null) continue
240
+ for (const [id, rawModel] of Object.entries(models as Record<string, unknown>)) {
241
+ if (typeof rawModel !== 'object' || rawModel === null) continue
242
+ const model = rawModel as Record<string, unknown>
243
+ const limit = (typeof model.limit === 'object' && model.limit !== null ? model.limit : {}) as Record<string, unknown>
244
+ const entry: CatalogEntry = { provider, id }
245
+ if (typeof model.name === 'string' && model.name.length > 0) entry.name = model.name
246
+ if (typeof limit.context === 'number') entry.context = limit.context
247
+ if (typeof limit.output === 'number') entry.output = limit.output
248
+ if (typeof model.reasoning === 'boolean') entry.reasoning = model.reasoning
249
+ const effortValues: string[] = []
250
+ if (Array.isArray(model.reasoning_options)) {
251
+ for (const option of model.reasoning_options) {
252
+ if (typeof option === 'object' && option !== null && (option as Record<string, unknown>).type === 'effort') {
253
+ const values = (option as Record<string, unknown>).values
254
+ if (Array.isArray(values)) {
255
+ for (const value of values) {
256
+ if (typeof value === 'string') effortValues.push(value)
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ if (effortValues.length > 0) entry.effortValues = effortValues
263
+ const modalities = (typeof model.modalities === 'object' && model.modalities !== null ? model.modalities : {}) as Record<string, unknown>
264
+ if (Array.isArray(modalities.input)) {
265
+ const input = modalities.input.filter((value): value is string => typeof value === 'string')
266
+ if (input.length > 0) entry.input = input
267
+ }
268
+ entries.push(entry)
269
+ }
270
+ }
271
+ return { entries, providers: Object.keys(body).length, fetchedAt }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Build the dsh `reasoningEfforts` identity dict from models.dev effort
277
+ * values, filtered to the levels dsh accepts. Returns undefined when nothing
278
+ * valid is offered (or the model does not state reasoning).
279
+ */
280
+ export function identityReasoningEfforts(entry: CatalogEntry): Record<string, string> | undefined {
281
+ if (entry.reasoning === false) return undefined
282
+ if (entry.effortValues === undefined || entry.effortValues.length === 0) return undefined
283
+ const efforts: Record<string, string> = {}
284
+ for (const value of entry.effortValues) {
285
+ if (REASONING_LEVELS.includes(value)) efforts[value] = value
286
+ }
287
+ return Object.keys(efforts).length > 0 ? efforts : undefined
288
+ }
289
+
290
+ /**
291
+ * The dsh-writable input modality list for one entry: models.dev values
292
+ * intersected with what pi-ai accepts. Returns undefined when nothing is
293
+ * accepted (writing an empty list is semantically "keep defaults", so absent
294
+ * is the honest fill).
295
+ */
296
+ export function acceptedInputModalities(entry: CatalogEntry): ('text' | 'image')[] | undefined {
297
+ if (entry.input === undefined || entry.input.length === 0) return undefined
298
+ const accepted = entry.input.filter((value): value is 'text' | 'image' => ACCEPTED_INPUT_MODALITIES.includes(value))
299
+ return accepted.length > 0 ? accepted : undefined
300
+ }