@fonlan/dsh-model-router 0.3.5 → 0.3.7

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.
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import type { Context } from '@deepseek-ai/cordis'
7
7
  import z from '@deepseek-ai/schemastery'
8
- import type { SettingsScope } from '@deepseek-ai/dsh-settings'
9
8
  import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
10
9
  import {
11
10
  initialConfigFor,
@@ -25,32 +24,40 @@ import { ModelRouterAdapter, type RouterFacts } from './adapter.js'
25
24
 
26
25
  export const NS = ROUTER_SETTINGS_NS
27
26
 
27
+ /**
28
+ * The slice of the host settings service this plugin writes through. dsh >=
29
+ * 0.1.7 owns plugin configuration as entry config (the plugin's `Config`
30
+ * schema), so the router reads the document it was applied with and persists
31
+ * updates via `settings.replace`, which writes the profile patch.
32
+ */
33
+ export interface SettingsReplaceFace {
34
+ replace(ns: string, section: object, expectedRevision?: number): Promise<void>
35
+ }
36
+
28
37
  /** Settings document schema: one entry per model id. */
29
38
  export const RouterConfigSchema: z<RouterConfigShape> = z.object({
39
+ // Volatile marks what the settings plane may write: the settings page and
40
+ // settings.replace both refuse non-volatile fields.
30
41
  models: z.dict(z.object({
31
42
  order: z.array(z.string()),
32
43
  active: z.string(),
33
- })),
44
+ })).volatile(),
34
45
  // Older documents predate the toggles; normalizeConfig defaults both on.
35
46
  // Schemastery object keys are input-optional, so absent is valid here.
36
- showQuickSwitch: z.boolean(),
37
- ignoreModelIdPrefix: z.boolean(),
38
- modelSort: z.union([z.const('custom'), z.const('name'), z.const('recent')]),
39
- modelOrder: z.array(z.string()),
40
- recentlyUsed: z.dict(z.number()),
47
+ showQuickSwitch: z.boolean().volatile(),
48
+ ignoreModelIdPrefix: z.boolean().volatile(),
49
+ modelSort: z.union([z.const('custom'), z.const('name'), z.const('recent')]).volatile(),
50
+ modelOrder: z.array(z.string()).volatile(),
51
+ recentlyUsed: z.dict(z.number()).volatile(),
41
52
  })
42
53
 
43
- const EMPTY_CONFIG: RouterConfigShape = {
44
- models: {},
45
- showQuickSwitch: true,
46
- ignoreModelIdPrefix: true,
47
- modelSort: 'custom',
48
- modelOrder: [],
49
- recentlyUsed: {},
50
- }
51
-
52
54
  /** The settings-page state view served by the API. */
53
55
  export interface ModelRouterState {
56
+ /**
57
+ * Whether this deployment can persist configuration. False (a profile
58
+ * without the host settings service) makes the page render read-only.
59
+ */
60
+ writable: boolean
54
61
  /** Whether provider switching is shown inside the composer model picker menu. */
55
62
  showQuickSwitch: boolean
56
63
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
@@ -83,28 +90,38 @@ export class ModelRouterService implements RouterFacts {
83
90
  }
84
91
  private inflight: Promise<RouterCatalog> | null = null
85
92
  private cachedAt = 0
86
- private scope: SettingsScope<RouterConfigShape> | undefined
93
+ /** Optional host settings service; absent in profiles without one (no persistence). */
94
+ private settings: SettingsReplaceFace | undefined
95
+ /** Live config document (seeded from the entry config, updated on write). */
96
+ private configDoc: RouterConfigShape
87
97
  private registration: AdapterRegistrationHandle | undefined
88
98
 
89
99
  /** How long a built catalog is considered fresh before being rebuilt. */
90
100
  private readonly catalogTtlMs = 30_000
91
101
 
92
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
93
- private readonly usageFlushMs = 1_500
102
+ /**
103
+ * Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
104
+ * long on purpose: every persist writes the entry config, which restarts
105
+ * this plugin's entry, so usage bookkeeping must not stream live traffic.
106
+ */
107
+ private readonly usageFlushMs = 60_000
94
108
  private usageDebounce: ReturnType<typeof setTimeout> | null = null
95
109
  private pendingUsage: Record<string, number> = {}
96
110
  private usageFlushRunning = false
97
111
 
98
- constructor(ctx: Context) {
112
+ constructor(ctx: Context, config: RouterConfigShape) {
99
113
  this.ctx = ctx
114
+ this.configDoc = normalizeConfig(config)
100
115
  }
101
116
 
102
- /** Register settings + adapter and wire topology listeners. */
117
+ /** Register the settings seam + adapter and wire topology listeners. */
103
118
  start(): void {
104
119
  // Optional settings service: without one the router still routes
105
- // (config falls back to catalog order) but cannot persist.
120
+ // (config falls back to catalog order) but cannot persist. The service's
121
+ // shape is only used through SettingsReplaceFace; there is no namespace
122
+ // registration to make on dsh >= 0.1.7.
106
123
  this.ctx.inject(['settings'], (sctx) => {
107
- this.scope = sctx.settings.register(NS, RouterConfigSchema) as SettingsScope<RouterConfigShape>
124
+ this.settings = (sctx as unknown as { settings?: SettingsReplaceFace }).settings
108
125
  })
109
126
 
110
127
  const adapter = new ModelRouterAdapter(this)
@@ -121,14 +138,17 @@ export class ModelRouterService implements RouterFacts {
121
138
  }
122
139
 
123
140
  stop(): void {
124
- // Flush any pending recent-use writes before the scope is torn down.
141
+ // Drop pending recent-use bookkeeping instead of flushing it: on dsh >=
142
+ // 0.1.7 every persist is an entry-config write, which reloads this plugin
143
+ // (teardown included), so writing from stop() would re-enter the loader
144
+ // while it is tearing this very entry down. The timestamps are best-effort
145
+ // ordering hints for the `recent` display mode, so losing the last
146
+ // debounce window is harmless.
125
147
  if (this.usageDebounce !== null) {
126
148
  clearTimeout(this.usageDebounce)
127
149
  this.usageDebounce = null
128
150
  }
129
- if (Object.keys(this.pendingUsage).length > 0) {
130
- void this.flushUsage()
131
- }
151
+ this.pendingUsage = {}
132
152
  this.registration?.()
133
153
  this.registration = undefined
134
154
  }
@@ -140,12 +160,7 @@ export class ModelRouterService implements RouterFacts {
140
160
  }
141
161
 
142
162
  config(): RouterConfigShape {
143
- if (this.scope === undefined) return EMPTY_CONFIG
144
- try {
145
- return normalizeConfig(this.scope.get())
146
- } catch {
147
- return EMPTY_CONFIG
148
- }
163
+ return this.configDoc
149
164
  }
150
165
 
151
166
  providerNames(): ReadonlyMap<string, string> {
@@ -163,7 +178,7 @@ export class ModelRouterService implements RouterFacts {
163
178
  * write.
164
179
  */
165
180
  noteModelUsed(modelId: string): void {
166
- if (this.scope === undefined) return
181
+ if (this.settings === undefined) return
167
182
  this.pendingUsage[modelId] = Date.now()
168
183
  if (this.usageDebounce !== null) return
169
184
  this.usageDebounce = setTimeout(() => {
@@ -179,9 +194,9 @@ export class ModelRouterService implements RouterFacts {
179
194
  const pending = this.pendingUsage
180
195
  this.pendingUsage = {}
181
196
  try {
182
- if (this.scope === undefined || Object.keys(pending).length === 0) return
197
+ if (this.settings === undefined || Object.keys(pending).length === 0) return
183
198
  const current = this.config()
184
- await this.scope.replace({
199
+ await this.writeConfig({
185
200
  ...current,
186
201
  recentlyUsed: { ...current.recentlyUsed, ...pending },
187
202
  })
@@ -233,12 +248,12 @@ export class ModelRouterService implements RouterFacts {
233
248
  /** Refresh (forced), then persist any config drift (migration, new/vanished models). */
234
249
  async refreshAndReconcile(): Promise<void> {
235
250
  const catalog = await this.refreshCatalog(true)
236
- if (this.scope === undefined) return
251
+ if (this.settings === undefined) return
237
252
  const current = this.config()
238
253
  const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
239
254
  if (!changed) return
240
255
  try {
241
- await this.scope.replace({
256
+ await this.writeConfig({
242
257
  ...current,
243
258
  models,
244
259
  ...(modelOrder === undefined ? {} : { modelOrder }),
@@ -264,6 +279,7 @@ export class ModelRouterService implements RouterFacts {
264
279
  const names = catalog.providerNames
265
280
  const creds = catalog.credentialConfigured
266
281
  return {
282
+ writable: this.settings !== undefined,
267
283
  showQuickSwitch: config.showQuickSwitch,
268
284
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
269
285
  modelSort: config.modelSort,
@@ -319,12 +335,9 @@ export class ModelRouterService implements RouterFacts {
319
335
 
320
336
  /** Toggle the composer quick route-switcher button (global, persisted). */
321
337
  async setShowQuickSwitch(value: boolean): Promise<void> {
322
- if (this.scope === undefined) {
323
- throw new Error('model-router: settings service is not available in this profile')
324
- }
325
338
  const current = this.config()
326
339
  if (current.showQuickSwitch === value) return
327
- await this.scope.replace({ ...current, showQuickSwitch: value })
340
+ await this.writeConfig({ ...current, showQuickSwitch: value })
328
341
  }
329
342
 
330
343
  /**
@@ -334,23 +347,17 @@ export class ModelRouterService implements RouterFacts {
334
347
  * turning on; split models re-initialize when turning off).
335
348
  */
336
349
  async setIgnoreModelIdPrefix(value: boolean): Promise<void> {
337
- if (this.scope === undefined) {
338
- throw new Error('model-router: settings service is not available in this profile')
339
- }
340
350
  const current = this.config()
341
351
  if (current.ignoreModelIdPrefix === value) return
342
- await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
352
+ await this.writeConfig({ ...current, ignoreModelIdPrefix: value })
343
353
  await this.refreshAndReconcile()
344
354
  }
345
355
 
346
356
  /** Switch the model-list display order mode (global, persisted). */
347
357
  async setModelSort(mode: ModelSortMode): Promise<void> {
348
- if (this.scope === undefined) {
349
- throw new Error('model-router: settings service is not available in this profile')
350
- }
351
358
  const current = this.config()
352
359
  if (current.modelSort === mode) return
353
- await this.scope.replace({ ...current, modelSort: mode })
360
+ await this.writeConfig({ ...current, modelSort: mode })
354
361
  }
355
362
 
356
363
  /**
@@ -359,9 +366,6 @@ export class ModelRouterService implements RouterFacts {
359
366
  * always covers the live catalog.
360
367
  */
361
368
  async setModelOrder(order: string[]): Promise<void> {
362
- if (this.scope === undefined) {
363
- throw new Error('model-router: settings service is not available in this profile')
364
- }
365
369
  await this.refreshCatalog()
366
370
  const ids = new Set(this.cached.models.map(model => model.id))
367
371
  const clean = order.filter(id => ids.has(id))
@@ -372,14 +376,20 @@ export class ModelRouterService implements RouterFacts {
372
376
  const sameOrder = clean.length === current.modelOrder.length
373
377
  && clean.every((id, at) => id === current.modelOrder[at])
374
378
  if (sameOrder) return
375
- await this.scope.replace({ ...current, modelOrder: clean })
379
+ await this.writeConfig({ ...current, modelOrder: clean })
376
380
  }
377
381
 
378
- private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
379
- if (this.scope === undefined) {
382
+ /** Write the document through the host settings service (entry config). */
383
+ private async writeConfig(next: RouterConfigShape): Promise<void> {
384
+ if (this.settings === undefined) {
380
385
  throw new Error('model-router: settings service is not available in this profile')
381
386
  }
387
+ await this.settings.replace(NS, next, undefined)
388
+ this.configDoc = normalizeConfig(next)
389
+ }
390
+
391
+ private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
382
392
  const current = this.config()
383
- await this.scope.replace({ ...current, models })
393
+ await this.writeConfig({ ...current, models })
384
394
  }
385
395
  }
@@ -463,13 +463,43 @@ export function setOrder(
463
463
  return { models: next, changed: true }
464
464
  }
465
465
 
466
+ /**
467
+ * Dereference one config field as the loader may hand it over.
468
+ *
469
+ * dsh >= 0.1.7 owns plugin configuration as entry config, and every field the
470
+ * `Config` schema marks `.volatile()` arrives as a stable REFERENCE
471
+ * (`{ get() }`, schemastery's `Volatile<T>`) instead of a plain value — a
472
+ * reference serializes to `{}`. Reading such a field raw silently yields
473
+ * `models: {}`, `modelSort: 'custom'`, an empty `modelOrder` and no
474
+ * `recentlyUsed`: the stored document is dropped, and the next persist
475
+ * overwrites it with catalog defaults. Plain values pass through unchanged, so
476
+ * this is safe for the write paths and for callers that already hold a
477
+ * resolved document.
478
+ */
479
+ function derefField<T>(value: T): T {
480
+ if (value !== null && typeof value === 'object' && typeof (value as { get?: unknown }).get === 'function') {
481
+ return (value as unknown as { get(): T }).get()
482
+ }
483
+ return value
484
+ }
485
+
486
+ /** Dereference every own field of a config object (per-field volatile refs). */
487
+ export function derefConfig(value: unknown): unknown {
488
+ const root = derefField(value)
489
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return root
490
+ const out: Record<string, unknown> = {}
491
+ for (const [key, field] of Object.entries(root as Record<string, unknown>)) out[key] = derefField(field)
492
+ return out
493
+ }
494
+
466
495
  /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
467
496
  export function normalizeConfig(value: unknown): RouterConfigShape {
468
497
  const models: Record<string, RouterModelConfig> = {}
469
- if (typeof value !== 'object' || value === null) {
498
+ const source = derefConfig(value)
499
+ if (typeof source !== 'object' || source === null) {
470
500
  return { models, showQuickSwitch: true, ignoreModelIdPrefix: true, modelSort: 'custom', modelOrder: [], recentlyUsed: {} }
471
501
  }
472
- const root = value as {
502
+ const root = source as {
473
503
  models?: unknown
474
504
  showQuickSwitch?: unknown
475
505
  ignoreModelIdPrefix?: unknown