@fonlan/dsh-model-parameters 0.1.4 → 0.1.6

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.
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Plugin configuration and shared constants for @fonlan/dsh-model-parameters.
3
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.
4
+ * The plugin's own settings ARE its loader entry config (DSH >= 0.1.7): the
5
+ * profile entry whose id is `model-parameters` (see cordis.patch.yml) is
6
+ * validated with {@link Config}, the resolved document is handed to
7
+ * `apply(ctx, config)`, and the settings page is derived from that schema. The
8
+ * plugin OBSERVES the `llm-pi-ai` namespace: whenever provider models are
9
+ * written there with missing capability fields (which is the norm for a freshly
10
+ * synced gateway), it patches the missing fields from the cached models.dev
11
+ * catalog.
9
12
  */
10
13
  import z from '@deepseek-ai/schemastery'
11
14
 
12
- /** This plugin's own settings namespace. */
15
+ /** This plugin's own settings namespace: its profile entry id. */
13
16
  export const PLUGIN_NS = 'model-parameters'
14
17
 
15
18
  /** The namespace whose models this plugin completes: pi-ai provider profiles. */
@@ -59,7 +62,7 @@ export const REASONING_LEVELS: readonly string[] = [
59
62
  'max',
60
63
  ]
61
64
 
62
- /** Plugin configuration shape. */
65
+ /** Plugin configuration shape: the PLAIN document, with no live handles in it. */
63
66
  export interface ModelParametersConfig {
64
67
  /** Master switch: when off, no fills happen (listener is inert). */
65
68
  enabled: boolean
@@ -81,16 +84,88 @@ export interface ModelParametersConfig {
81
84
  officialProviders: string[]
82
85
  }
83
86
 
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]),
87
+ /**
88
+ * Recursively readonly value a live handle returns — cosmokit's
89
+ * `VolatileSnapshot<T>`, spelled locally for the same reason as
90
+ * {@link LiveSettingsField}.
91
+ */
92
+ export type LiveSettingsSnapshot<T> = T extends object
93
+ ? { readonly [K in keyof T]: LiveSettingsSnapshot<T[K]> }
94
+ : T
95
+
96
+ /**
97
+ * One `volatile()` config field as the loader hands it to `apply()`: a live
98
+ * handle, not the value it currently holds. Spelled structurally rather than as
99
+ * cosmokit's `Volatile<T>` on purpose — cosmokit is a transitive dependency
100
+ * here, and importing it would put a second schemastery/cosmokit pair into the
101
+ * emitted declaration.
102
+ */
103
+ export interface LiveSettingsField<T> {
104
+ get(): LiveSettingsSnapshot<T>
105
+ }
106
+
107
+ /** A hand-written entry config (profile patch / test fixture): every field is optional. */
108
+ export type ModelParametersConfigInput = {
109
+ enabled?: boolean | null
110
+ ttlDays?: number | null
111
+ fillName?: boolean | null
112
+ fillContext?: boolean | null
113
+ fillMaxTokens?: boolean | null
114
+ fillReasoning?: boolean | null
115
+ fillInput?: boolean | null
116
+ providerMap?: Record<string, string> | null
117
+ officialProviders?: string[] | null
118
+ }
119
+
120
+ /** The resolved entry config `apply()` receives: every field is a live handle. */
121
+ export type ResolvedModelParametersConfig = {
122
+ enabled: LiveSettingsField<boolean>
123
+ ttlDays: LiveSettingsField<number>
124
+ fillName: LiveSettingsField<boolean>
125
+ fillContext: LiveSettingsField<boolean>
126
+ fillMaxTokens: LiveSettingsField<boolean>
127
+ fillReasoning: LiveSettingsField<boolean>
128
+ fillInput: LiveSettingsField<boolean>
129
+ providerMap: LiveSettingsField<Record<string, string>>
130
+ officialProviders: LiveSettingsField<string[]>
131
+ }
132
+
133
+ /**
134
+ * Entry config schema. Deliberately NOT annotated `z<ModelParametersConfig>`:
135
+ * schemastery >= 3.18.3 carries a third `Mode` type parameter, so a
136
+ * `.volatile()` field's OUTPUT type is `Volatile<T>` (a live `{ get() }` cell),
137
+ * and that annotation no longer compiles. The plain document shape stays
138
+ * {@link ModelParametersConfig} and is what {@link readConfig} projects to.
139
+ *
140
+ * The annotation below names the type through the package's own default export
141
+ * (`z<...>`), with locally declared type arguments: the bare inferred type of a
142
+ * volatile schema resolves through a schemastery copy this package does not
143
+ * import, so `tsc -p tsconfig.build.json` (`declaration: true`) could not name
144
+ * it (TS2742) — the emitted declaration stays portable this way.
145
+ *
146
+ * `.volatile()` is REQUIRED on every field the settings page may write, not
147
+ * cosmetic: `dsh-settings` derives `volatileForm(schema)` from this schema, and
148
+ * with no volatile field at all it SKIPS this entry in `describe()` (the
149
+ * settings page disappears) and REJECTS every write as a non-volatile path.
150
+ * The profile entry id (`model-parameters`, see cordis.patch.yml) is the
151
+ * namespace those writes address.
152
+ */
153
+ export type ModelParametersConfigSchema = z<
154
+ ModelParametersConfigInput,
155
+ ResolvedModelParametersConfig
156
+ >
157
+
158
+ /** The plugin's `Config` schema, exported for the loader and the settings page. */
159
+ export const Config: ModelParametersConfigSchema = z.object({
160
+ enabled: z.boolean().default(true).volatile(),
161
+ ttlDays: z.number().min(1).max(90).default(7).volatile(),
162
+ fillName: z.boolean().default(true).volatile(),
163
+ fillContext: z.boolean().default(true).volatile(),
164
+ fillMaxTokens: z.boolean().default(true).volatile(),
165
+ fillReasoning: z.boolean().default(true).volatile(),
166
+ fillInput: z.boolean().default(true).volatile(),
167
+ providerMap: z.dict(z.string()).default({}).volatile(),
168
+ officialProviders: z.array(z.string()).default([...DEFAULT_OFFICIAL_PROVIDERS]).volatile(),
94
169
  })
95
170
 
96
171
  export const DEFAULT_CONFIG: ModelParametersConfig = {
@@ -104,3 +179,51 @@ export const DEFAULT_CONFIG: ModelParametersConfig = {
104
179
  providerMap: {},
105
180
  officialProviders: [...DEFAULT_OFFICIAL_PROVIDERS],
106
181
  }
182
+
183
+ /**
184
+ * The entry config as `apply()` receives it. Every field may arrive either as a
185
+ * live `Volatile` cell or as a plain value (a hand-built config object, a
186
+ * profile patch written before the volatile marker existed, or a host that
187
+ * resolves the document without the wrapper).
188
+ */
189
+ export type ModelParametersLiveConfig = Partial<Record<keyof ModelParametersConfig, unknown>>
190
+
191
+ /**
192
+ * Read a config node that may be a plain value or a live (`.volatile()`) node.
193
+ *
194
+ * A plugin whose Config declares volatile fields receives a reactive config
195
+ * whose fields expose `get()`, while hand-written config objects and tests pass
196
+ * plain values. Both shapes are supported here so the same code path serves the
197
+ * settings page, `cordis.patch.yml`, and the unit tests.
198
+ */
199
+ function liveValue<T>(node: unknown, fallback: T): T {
200
+ if (node === undefined || node === null) return fallback
201
+ const getter = (node as { get?: unknown }).get
202
+ if (typeof getter === 'function') {
203
+ const value = (getter as () => unknown).call(node)
204
+ return value === undefined ? fallback : (value as T)
205
+ }
206
+ return node as T
207
+ }
208
+
209
+ /**
210
+ * Project the loader entry config into the plain document shape, per field
211
+ * falling back to {@link DEFAULT_CONFIG}. The config is re-read on EVERY call,
212
+ * so settings-page edits (which the loader swaps into the live cells in place)
213
+ * take effect immediately. Container fields are copied so callers can never
214
+ * mutate the live document through a returned reference.
215
+ */
216
+ export function readConfig(config: ModelParametersLiveConfig | undefined): ModelParametersConfig {
217
+ const section = config ?? {}
218
+ return {
219
+ enabled: liveValue(section.enabled, DEFAULT_CONFIG.enabled),
220
+ ttlDays: liveValue(section.ttlDays, DEFAULT_CONFIG.ttlDays),
221
+ fillName: liveValue(section.fillName, DEFAULT_CONFIG.fillName),
222
+ fillContext: liveValue(section.fillContext, DEFAULT_CONFIG.fillContext),
223
+ fillMaxTokens: liveValue(section.fillMaxTokens, DEFAULT_CONFIG.fillMaxTokens),
224
+ fillReasoning: liveValue(section.fillReasoning, DEFAULT_CONFIG.fillReasoning),
225
+ fillInput: liveValue(section.fillInput, DEFAULT_CONFIG.fillInput),
226
+ providerMap: { ...liveValue<Record<string, string>>(section.providerMap, DEFAULT_CONFIG.providerMap) },
227
+ officialProviders: [...liveValue<readonly string[]>(section.officialProviders, DEFAULT_CONFIG.officialProviders)],
228
+ }
229
+ }