@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.
- package/LICENSE +21 -0
- package/README.en.md +140 -0
- package/README.md +112 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +586 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +637 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/server/catalog.d.ts +83 -0
- package/lib/types/server/rpc.d.ts +3 -0
- package/lib/types/server/service.d.ts +66 -0
- package/lib/types/shared/config.d.ts +53 -0
- package/package.json +98 -0
- package/src/client/api.ts +59 -0
- package/src/client/index.tsx +33 -0
- package/src/client/locales.ts +95 -0
- package/src/client/settings-section.css +172 -0
- package/src/client/settings-section.tsx +310 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +47 -0
- package/src/server/catalog.ts +300 -0
- package/src/server/rpc.ts +162 -0
- package/src/server/service.ts +258 -0
- package/src/shared/config.ts +106 -0
|
@@ -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
|
+
}
|