@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,162 @@
1
+ /**
2
+ * Fenced JSON API under /plugins/@fonlan/dsh-model-parameters/api/<method> for
3
+ * the web settings page. Same browser-trust fence as the /api gateway and the
4
+ * model-router plugin: loopback Host-header or configured trustedHosts,
5
+ * same-origin browser markers.
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import type {} from '@deepseek-ai/dsh-host-webserver'
10
+ import type { ModelParametersService } from './service.js'
11
+
12
+ const API_PREFIX = '/plugins/@fonlan/dsh-model-parameters/api'
13
+
14
+ // ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
15
+
16
+ function header(headers: IncomingMessage['headers'], name: string): string | undefined {
17
+ const value = headers[name]
18
+ return typeof value === 'string' ? value : undefined
19
+ }
20
+
21
+ function isLoopbackHostname(hostname: string): boolean {
22
+ if (hostname === 'localhost' || hostname === '[::1]') return true
23
+ const parts = hostname.split('.')
24
+ return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
25
+ }
26
+
27
+ function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
28
+ const host = header(req.headers, 'host')
29
+ if (host === undefined) return false
30
+ let hostUrl: URL
31
+ try {
32
+ hostUrl = new URL(`http://${host}`)
33
+ } catch {
34
+ return false
35
+ }
36
+ if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
37
+ return false
38
+ }
39
+ if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
40
+ const origin = header(req.headers, 'origin')
41
+ if (origin === undefined) return true
42
+ try {
43
+ return new URL(origin).host === hostUrl.host
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ function trustedHostsOf(ctx: Context): string[] {
50
+ for (const entry of ctx.get('loader')?.entries?.() ?? []) {
51
+ if (entry.options?.name === 'connection') {
52
+ const config = entry.options.config as { trustedHosts?: string[] } | undefined
53
+ return config?.trustedHosts ?? []
54
+ }
55
+ }
56
+ return []
57
+ }
58
+
59
+ // ── wire helpers ────────────────────────────────────────────────────────────
60
+
61
+ function writeJson(res: ServerResponse, body: unknown): void {
62
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
63
+ res.end(JSON.stringify(body))
64
+ }
65
+
66
+ function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
67
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
68
+ res.end(JSON.stringify({ ok: false, error: { code, message } }))
69
+ }
70
+
71
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
72
+ const chunks: Buffer[] = []
73
+ for await (const chunk of req) chunks.push(chunk as Buffer)
74
+ const raw = Buffer.concat(chunks).toString('utf8')
75
+ if (raw.trim() === '') return {}
76
+ try {
77
+ const parsed = JSON.parse(raw) as unknown
78
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
79
+ } catch {
80
+ return {}
81
+ }
82
+ }
83
+
84
+ function requireString(value: unknown, name: string): string {
85
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`)
86
+ return value
87
+ }
88
+
89
+ // ── routes ──────────────────────────────────────────────────────────────────
90
+
91
+ export function registerApiRoutes(ctx: Context, service: ModelParametersService): () => void {
92
+ const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
93
+ const disposers: Array<() => void> = []
94
+
95
+ const route = (name: string, handler: (payload: Record<string, unknown>) => Promise<unknown>): void => {
96
+ disposers.push(ctx.webServer.register({
97
+ kind: 'prefix',
98
+ path: `${API_PREFIX}/${name}`,
99
+ handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
100
+ if (!fence(req)) {
101
+ writeError(res, 'forbidden', 'forbidden', 403)
102
+ return
103
+ }
104
+ if (req.method !== 'POST' && req.method !== 'GET') {
105
+ writeError(res, 'method', 'method not allowed', 405)
106
+ return
107
+ }
108
+ const payload = await readJsonBody(req)
109
+ try {
110
+ const value = await handler(payload)
111
+ writeJson(res, { ok: true, value })
112
+ } catch (error) {
113
+ writeError(res, 'error', error instanceof Error ? error.message : String(error))
114
+ }
115
+ },
116
+ }))
117
+ }
118
+
119
+ route('state', async () => stateOf(service))
120
+
121
+ route('refresh', async () => {
122
+ await service.refreshAndReconcile()
123
+ return stateOf(service)
124
+ })
125
+
126
+ route('reconcile', async () => {
127
+ await service.reconcile()
128
+ return stateOf(service)
129
+ })
130
+
131
+ route('set-config', async (payload) => {
132
+ const patch = payload.patch
133
+ if (typeof patch !== 'object' || patch === null) throw new Error('patch must be an object')
134
+ await service.updateConfig(patch as never)
135
+ return stateOf(service)
136
+ })
137
+
138
+ return () => {
139
+ for (const dispose of disposers) dispose()
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The settings-page view. Deliberately ships only catalog COUNTS — the full
145
+ * entries array (thousands of models) must never cross the wire or render.
146
+ */
147
+ function stateOf(service: ModelParametersService): unknown {
148
+ const config = service.config()
149
+ const snapshot = service.catalogSnapshot()
150
+ return {
151
+ config,
152
+ catalog: {
153
+ entries: snapshot.entries.length,
154
+ providers: snapshot.providers,
155
+ fetchedAt: snapshot.fetchedAt,
156
+ fresh: snapshot.fetchedAt > 0
157
+ && Date.now() - snapshot.fetchedAt < config.ttlDays * 24 * 60 * 60 * 1000,
158
+ ttlDays: config.ttlDays,
159
+ },
160
+ report: service.report(),
161
+ }
162
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The model-parameters service: owns the plugin settings namespace, watches
3
+ * the `llm-pi-ai` namespace for provider model writes, reconciles missing
4
+ * fields from the cached models.dev catalog, and tracks a fill report.
5
+ *
6
+ * Trigger model:
7
+ * - any committed `llm-pi-ai` settings change (settings UI apply, manual
8
+ * settings.yaml edit published by the file provider, model-router-style
9
+ * external writes) runs a reconcile;
10
+ * - plugin start runs a backfill reconcile over the existing configuration.
11
+ * Reconcile is fill-only: it never overwrites a present field, and it writes
12
+ * nothing when nothing is missing — so its own writes cannot re-trigger a
13
+ * second patch (the next event sees the fields present and produces no ops).
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import {
17
+ settingsNamespace,
18
+ type SettingsScope,
19
+ type SettingsPathOp,
20
+ } from '@deepseek-ai/dsh-settings'
21
+ import type { PiAiModelProfile } from '@deepseek-ai/dsh-llm-pi-ai'
22
+ import {
23
+ Config,
24
+ DEFAULT_CONFIG,
25
+ PLUGIN_NS,
26
+ TARGET_NS,
27
+ type ModelParametersConfig,
28
+ } from '../shared/config.js'
29
+ import {
30
+ CatalogManager,
31
+ acceptedInputModalities,
32
+ identityReasoningEfforts,
33
+ type CatalogEntry,
34
+ type CatalogSnapshot,
35
+ } from './catalog.js'
36
+
37
+ const TARGET = settingsNamespace(TARGET_NS)
38
+ const OWN = settingsNamespace(PLUGIN_NS)
39
+
40
+ /** One reconcile run's outcome, surfaced to the settings page. */
41
+ export interface FillReport {
42
+ at: number
43
+ /** Number of individual field fills applied. */
44
+ filled: number
45
+ /** Number of providers whose models array was rewritten. */
46
+ touched: number
47
+ /** Model ids (provider/model) that matched nothing in the catalog. */
48
+ unmatched: string[]
49
+ }
50
+
51
+ export class ModelParametersService {
52
+ /**
53
+ * The settings service value captured from the inject callback. The plugin
54
+ * itself declares `inject: []` (settings is optional — headless profiles
55
+ * have no settings service), so `this.ctx.settings` is never resolvable:
56
+ * the context proxy only walks ancestor fibers, and the provider lives on
57
+ * a sibling fiber. The injected child ctx itself is only valid while its
58
+ * fiber is active — the provider can be reloaded (and every dependent
59
+ * fiber torn down) while an async reconcile is in flight — so all reads
60
+ * and writes go through the captured service value, which is a plain
61
+ * object and never walks the fiber chain.
62
+ */
63
+ private settings: Context['settings'] | undefined
64
+ private scope: SettingsScope<ModelParametersConfig> | undefined
65
+ private lastReport: FillReport | null = null
66
+ private readonly catalog: CatalogManager
67
+ private reconciling: Promise<void> | null = null
68
+
69
+ constructor(private readonly ctx: Context) {
70
+ this.catalog = new CatalogManager(ctx.logger)
71
+ }
72
+
73
+ start(): void {
74
+ // Own settings namespace (config toggles, TTL, provider map). The inject
75
+ // callback fires asynchronously once the settings service is available, so
76
+ // the first-run backfill reconcile lives INSIDE it — reconcile needs the
77
+ // settings service (to read the target namespace and persist fills).
78
+ this.ctx.inject(['settings'], (sctx) => {
79
+ this.settings = sctx.settings
80
+ this.scope = this.settings.register(OWN, Config) as SettingsScope<ModelParametersConfig>
81
+ // First-run backfill: ensure a fresh-enough catalog, then fill existing
82
+ // models. Never blocks plugin startup.
83
+ void this.reconcile()
84
+ })
85
+
86
+ // Watch the target namespace: any committed provider-model change may
87
+ // leave fields missing -> reconcile.
88
+ this.ctx.on('settings/updated', (ns) => {
89
+ if (ns !== TARGET) return
90
+ void this.reconcile()
91
+ })
92
+ }
93
+
94
+ stop(): void {
95
+ this.scope = undefined
96
+ this.settings = undefined
97
+ }
98
+
99
+ // ── reads for the settings page ────────────────────────────────────────────
100
+
101
+ config(): ModelParametersConfig {
102
+ if (this.scope !== undefined) {
103
+ try {
104
+ return this.scope.get()
105
+ } catch {
106
+ // fall through to defaults
107
+ }
108
+ }
109
+ return { ...DEFAULT_CONFIG, providerMap: {}, officialProviders: [...DEFAULT_CONFIG.officialProviders] }
110
+ }
111
+
112
+ catalogSnapshot(): CatalogSnapshot {
113
+ return this.catalog.snapshotOf()
114
+ }
115
+
116
+ report(): FillReport | null {
117
+ return this.lastReport
118
+ }
119
+
120
+ /** Force a catalog refresh, then reconcile and report. */
121
+ async refreshAndReconcile(): Promise<void> {
122
+ await this.catalog.refresh(this.config().ttlDays)
123
+ await this.reconcile()
124
+ }
125
+
126
+ /** Update plugin config toggles from the settings page. */
127
+ async updateConfig(patch: Partial<ModelParametersConfig>): Promise<void> {
128
+ if (this.scope === undefined) {
129
+ throw new Error('model-parameters: settings service is not available in this profile')
130
+ }
131
+ const current = this.config()
132
+ await this.scope.update({ ...current, ...patch })
133
+ }
134
+
135
+ // ── reconcile ──────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Fill missing fields on every provider model from the catalog. Serialized:
139
+ * concurrent triggers (event + backfill) coalesce into one run. Fill-only
140
+ * guarantees termination: after a write the fields are present, so the next
141
+ * event produces no ops.
142
+ */
143
+ async reconcile(): Promise<void> {
144
+ if (this.reconciling !== null) return this.reconciling
145
+ this.reconciling = this.runReconcile().finally(() => {
146
+ this.reconciling = null
147
+ })
148
+ return this.reconciling
149
+ }
150
+
151
+ private async runReconcile(): Promise<void> {
152
+ const config = this.config()
153
+ if (!config.enabled) return
154
+ const settings = this.settings
155
+ if (settings === undefined) return
156
+ try {
157
+ await this.catalog.ensureFresh(config)
158
+ } catch (error) {
159
+ this.ctx.logger.warn(`model-parameters: reconcile skipped, catalog unavailable: ${error instanceof Error ? error.message : String(error)}`)
160
+ return
161
+ }
162
+ // Operate on the RAW user section (descriptor.user), not the resolved
163
+ // value: the resolved section is schema-normalized (pi-ai's loose object
164
+ // resolution adds empty `input`/`compat` defaults), and persisting that
165
+ // shape would leak schema-default noise into settings.yaml. The raw
166
+ // section is exactly what the user document stores.
167
+ const descriptor = settings.describe({ redactSecrets: false }).find((candidate) => candidate.ns === TARGET)
168
+ const section = descriptor?.user as { providers?: Record<string, { models?: PiAiModelProfile[] }> } | undefined
169
+ if (section === undefined || typeof section !== 'object' || section.providers === undefined) return
170
+
171
+ const ops: SettingsPathOp[] = []
172
+ const report: FillReport = { at: Date.now(), filled: 0, touched: 0, unmatched: [] }
173
+
174
+ for (const [providerId, profile] of Object.entries(section.providers)) {
175
+ if (typeof profile !== 'object' || profile === null) continue
176
+ const models = profile.models
177
+ if (!Array.isArray(models) || models.length === 0) continue
178
+ const nextModels: PiAiModelProfile[] = []
179
+ let changed = false
180
+ for (const model of models) {
181
+ const next = this.fillModel(model, providerId, config, report)
182
+ if (next !== model) changed = true
183
+ nextModels.push(next)
184
+ }
185
+ if (changed) {
186
+ ops.push({ op: 'set', path: ['providers', providerId, 'models'], value: nextModels })
187
+ report.touched++
188
+ }
189
+ }
190
+
191
+ if (ops.length > 0) {
192
+ try {
193
+ await settings.mutate(TARGET, ops)
194
+ } catch (error) {
195
+ this.ctx.logger.warn(`model-parameters: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
196
+ }
197
+ }
198
+ this.lastReport = report
199
+ if (report.filled > 0 || report.unmatched.length > 0) {
200
+ this.ctx.logger.info(
201
+ `model-parameters: reconcile filled ${report.filled} fields across ${report.touched} providers; ${report.unmatched.length} unmatched (${report.unmatched.slice(0, 5).join(', ')}${report.unmatched.length > 5 ? ', …' : ''})`,
202
+ )
203
+ }
204
+ }
205
+
206
+ /** Fill one model entry (fill-only), returning the same object when untouched. */
207
+ private fillModel(
208
+ model: PiAiModelProfile,
209
+ providerId: string,
210
+ config: ModelParametersConfig,
211
+ report: FillReport,
212
+ ): PiAiModelProfile {
213
+ const entry = this.catalog.resolve(model.id, providerId, config)
214
+ if (entry === undefined) {
215
+ report.unmatched.push(`${providerId}/${model.id}`)
216
+ return model
217
+ }
218
+ const next = { ...model }
219
+ let filledFields = 0
220
+
221
+ if (config.fillName && next.name === undefined) {
222
+ const name = catalogName(entry)
223
+ if (name !== undefined) {
224
+ next.name = name
225
+ filledFields++
226
+ }
227
+ }
228
+ if (config.fillContext && next.contextWindow === undefined && entry.context !== undefined && entry.context > 0) {
229
+ next.contextWindow = entry.context
230
+ filledFields++
231
+ }
232
+ if (config.fillMaxTokens && next.maxTokens === undefined && entry.output !== undefined && entry.output > 0) {
233
+ next.maxTokens = entry.output
234
+ filledFields++
235
+ }
236
+ if (config.fillReasoning && next.reasoningEfforts === undefined) {
237
+ const efforts = identityReasoningEfforts(entry)
238
+ if (efforts !== undefined) {
239
+ next.reasoningEfforts = efforts
240
+ filledFields++
241
+ }
242
+ }
243
+ if (config.fillInput && next.input === undefined) {
244
+ const input = acceptedInputModalities(entry)
245
+ if (input !== undefined) {
246
+ next.input = input
247
+ filledFields++
248
+ }
249
+ }
250
+ if (filledFields === 0) return model
251
+ report.filled += filledFields
252
+ return next
253
+ }
254
+ }
255
+
256
+ function catalogName(entry: CatalogEntry): string | undefined {
257
+ return entry.name !== undefined && entry.name.length > 0 ? entry.name : undefined
258
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Plugin configuration and shared constants for @fonlan/dsh-model-parameters.
3
+ *
4
+ * The plugin owns the `model-parameters` settings namespace (stored in
5
+ * settings.yaml) and OBSERVES the `llm-pi-ai` namespace: whenever provider
6
+ * models are written there with missing capability fields (which is the norm
7
+ * for a freshly synced gateway), it patches the missing fields from the cached
8
+ * models.dev catalog.
9
+ */
10
+ import z from '@deepseek-ai/schemastery'
11
+
12
+ /** This plugin's own settings namespace. */
13
+ export const PLUGIN_NS = 'model-parameters'
14
+
15
+ /** The namespace whose models this plugin completes: pi-ai provider profiles. */
16
+ export const TARGET_NS = 'llm-pi-ai'
17
+
18
+ /** models.dev catalog endpoint. */
19
+ export const CATALOG_URL = 'https://models.dev/api.json'
20
+
21
+ /** Default provider ids treated as first-party vendors (tie-break for id collisions). */
22
+ export const DEFAULT_OFFICIAL_PROVIDERS: readonly string[] = [
23
+ 'deepseek',
24
+ 'openai',
25
+ 'anthropic',
26
+ 'google',
27
+ 'xai',
28
+ 'moonshotai',
29
+ 'zai',
30
+ 'zhipuai',
31
+ 'minimax',
32
+ 'stepfun',
33
+ 'xiaomi',
34
+ 'meta',
35
+ 'cohere',
36
+ 'nvidia',
37
+ 'alibaba',
38
+ ]
39
+
40
+ /**
41
+ * dsh pi-ai only accepts these request modalities; models.dev may also list
42
+ * pdf/audio/video, which must be filtered out before writing (schema rejects
43
+ * unknown modality values).
44
+ */
45
+ export const ACCEPTED_INPUT_MODALITIES: readonly string[] = ['text', 'image']
46
+
47
+ /**
48
+ * Valid `reasoningEfforts` keys in pi-ai, in escalation order. models.dev
49
+ * effort values like `default`/`none`/empty are filtered out; only values in
50
+ * this set are written.
51
+ */
52
+ export const REASONING_LEVELS: readonly string[] = [
53
+ 'off',
54
+ 'minimal',
55
+ 'low',
56
+ 'medium',
57
+ 'high',
58
+ 'xhigh',
59
+ 'max',
60
+ ]
61
+
62
+ /** Plugin configuration shape. */
63
+ export interface ModelParametersConfig {
64
+ /** Master switch: when off, no fills happen (listener is inert). */
65
+ enabled: boolean
66
+ /** How many days a cached models.dev catalog is considered fresh. */
67
+ ttlDays: number
68
+ /** Fill display name from the catalog when the entry lacks one. */
69
+ fillName: boolean
70
+ /** Fill contextWindow when the entry lacks one. */
71
+ fillContext: boolean
72
+ /** Fill maxTokens when the entry lacks one. */
73
+ fillMaxTokens: boolean
74
+ /** Fill reasoningEfforts (identity dict over offered effort levels) when absent. */
75
+ fillReasoning: boolean
76
+ /** Fill input modalities (filtered to text/image) when absent. */
77
+ fillInput: boolean
78
+ /** dsh provider id -> models.dev provider id overrides for matching. */
79
+ providerMap: Record<string, string>
80
+ /** models.dev provider ids treated as first-party vendors for tie-breaking. */
81
+ officialProviders: string[]
82
+ }
83
+
84
+ export const Config: z<ModelParametersConfig> = z.object({
85
+ enabled: z.boolean().default(true),
86
+ ttlDays: z.number().min(1).max(90).default(7),
87
+ fillName: z.boolean().default(true),
88
+ fillContext: z.boolean().default(true),
89
+ fillMaxTokens: z.boolean().default(true),
90
+ fillReasoning: z.boolean().default(true),
91
+ fillInput: z.boolean().default(true),
92
+ providerMap: z.dict(z.string()).default({}),
93
+ officialProviders: z.array(z.string()).default([...DEFAULT_OFFICIAL_PROVIDERS]),
94
+ })
95
+
96
+ export const DEFAULT_CONFIG: ModelParametersConfig = {
97
+ enabled: true,
98
+ ttlDays: 7,
99
+ fillName: true,
100
+ fillContext: true,
101
+ fillMaxTokens: true,
102
+ fillReasoning: true,
103
+ fillInput: true,
104
+ providerMap: {},
105
+ officialProviders: [...DEFAULT_OFFICIAL_PROVIDERS],
106
+ }