@fonlan/dsh-model-router 0.3.6 → 0.3.8

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.
@@ -15,27 +15,22 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
15
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
16
16
  import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
17
17
  import { LOCALE_NS, zh, en } from './locales'
18
- import { makeSettingsSection, type SettingsScopeFace } from './settings-section'
18
+ import { makeSettingsSection } from './settings-section'
19
19
  import { registerModelRouterSeat } from './model-seat'
20
20
 
21
- /** The settings namespace this page edits (must match the host half). */
22
- const ROUTER_NS = 'model-router'
23
-
24
21
  /** The settings sidebar entry this page owns (must stay stable). */
25
22
  const SECTION_ID = 'model-router'
26
23
 
27
24
  /** Slots face: the settings.section list slot is declared at runtime by the
28
25
  * settings shell (ui-settings-general), which external plugins do not depend
29
26
  * on; the register call below is typed through this local face (erased at
30
- * build time). Its inject face binds the model-router settings scope, which
31
- * reaches the component as the scope prop. */
27
+ * build time). */
32
28
  interface SectionEntry {
33
29
  name: string
34
30
  id?: string
35
31
  order?: number
36
32
  label?: () => string
37
33
  locale?: string
38
- inject?: () => unknown
39
34
  }
40
35
 
41
36
  interface Slots {
@@ -47,7 +42,7 @@ interface Slots {
47
42
  * `remote.session` is included like the host ui-model-selection declares it:
48
43
  * the seat's `directoryFor` depends on the namespace, which mounts
49
44
  * asynchronously at startup. */
50
- export const inject = ['slots', 'locale', 'modelDirectories', 'sessions', 'settingsScope', 'connection', 'remote', 'remote.session']
45
+ export const inject = ['slots', 'locale', 'modelDirectories', 'sessions', 'connection', 'remote', 'remote.session']
51
46
 
52
47
  /** Client plugin body. */
53
48
  export function apply(ctx: ClientContext): void {
@@ -60,24 +55,24 @@ export function apply(ctx: ClientContext): void {
60
55
  const SettingsSection = makeSettingsSection(ctx)
61
56
 
62
57
  // The page registers on the settings.section list slot under a stable id, so
63
- // it appears in the settings sidebar's left navigation. The bound
64
- // model-router settings scope (the same namespace the host half registers)
65
- // travels through the inject face and reaches the component as scope.
66
- const services = ctx as unknown as {
67
- slots: Slots
68
- settingsScope: { bind(spec: { namespace: string }): SettingsScopeFace }
69
- }
70
- const scope = services.settingsScope.bind({ namespace: ROUTER_NS })
71
-
72
- services.slots.inject('settings.section', () =>
73
- services.slots.register(
58
+ // it appears in the settings sidebar's left navigation.
59
+ //
60
+ // dsh >= 0.1.7 dropped the client-side `settingsScope` service (plugin
61
+ // configuration became loader-entry config, projected by the host), and the
62
+ // old conditional registration therefore never fired: the section silently
63
+ // disappeared from Settings. Register unconditionally — the page reads and
64
+ // writes through this plugin's own fenced API (see ./settings-section), so
65
+ // it needs no settings service at all. Never gate a slot registration on a
66
+ // service that may not exist: the failure mode is an invisible page.
67
+ const slots = (ctx as unknown as { slots: Slots }).slots
68
+ slots.inject('settings.section', () =>
69
+ slots.register(
74
70
  {
75
71
  name: 'settings.section',
76
72
  id: SECTION_ID,
77
73
  order: 300,
78
74
  label: () => t('settingsTitle'),
79
75
  locale: LOCALE_NS,
80
- inject: () => ({ scope }),
81
76
  },
82
77
  SettingsSection,
83
78
  ),
@@ -15,15 +15,10 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
15
15
  type ClientContext = Context
16
16
  import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
17
17
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
18
- import {
19
- IconCheckOutline16,
20
- IconChevronDownOutline14,
21
- IconChevronRightOutline14,
22
- IconWarningOutline16,
23
- Toast,
24
- } from '@deepseek-ai/dsh-client-ui-primitives'
18
+ import { Toast } from '@deepseek-ai/dsh-client-ui-primitives'
25
19
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
26
20
  import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
21
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, WarningIcon } from './icons'
27
22
  import { api, type ModelRouterState, type RouterModelView } from './api'
28
23
  import { LOCALE_NS } from './locales'
29
24
  import './model-seat.css'
@@ -392,7 +387,7 @@ export function ModelRouterSeat({
392
387
  <span className="mr-seat-triggerEffort">{effortLabel}</span>
393
388
  )}
394
389
  <span className={`mr-seat-chevron${open ? ' mr-seat-chevronOpen' : ''}`} aria-hidden>
395
- <IconChevronDownOutline14 />
390
+ <ChevronDownIcon />
396
391
  </span>
397
392
  </button>
398
393
  {open && (
@@ -415,7 +410,7 @@ export function ModelRouterSeat({
415
410
  <span className="mr-seat-cellLabel">{t('menuModel')}</span>
416
411
  <span className="mr-seat-cellValue">{modelLabel}</span>
417
412
  <span className="mr-seat-cellChevron" aria-hidden>
418
- <IconChevronRightOutline14 />
413
+ <ChevronRightIcon />
419
414
  </span>
420
415
  </button>
421
416
  {providerRowVisible && (
@@ -429,7 +424,7 @@ export function ModelRouterSeat({
429
424
  <span className="mr-seat-cellLabel">{t('menuProvider')}</span>
430
425
  <span className="mr-seat-cellValue">{activeProviderName}</span>
431
426
  <span className="mr-seat-cellChevron" aria-hidden>
432
- <IconChevronRightOutline14 />
427
+ <ChevronRightIcon />
433
428
  </span>
434
429
  </button>
435
430
  )}
@@ -444,7 +439,7 @@ export function ModelRouterSeat({
444
439
  <span className="mr-seat-cellLabel">{t('menuEffort')}</span>
445
440
  <span className="mr-seat-cellValue">{effortLabel}</span>
446
441
  <span className="mr-seat-cellChevron" aria-hidden>
447
- <IconChevronRightOutline14 />
442
+ <ChevronRightIcon />
448
443
  </span>
449
444
  </button>
450
445
  )}
@@ -505,7 +500,7 @@ export function ModelRouterSeat({
505
500
  )}
506
501
  </span>
507
502
  <span className="mr-seat-check" aria-hidden>
508
- {selected ? <IconCheckOutline16 /> : null}
503
+ {selected ? <CheckIcon /> : null}
509
504
  </span>
510
505
  </button>
511
506
  )
@@ -562,7 +557,7 @@ export function ModelRouterSeat({
562
557
  )}
563
558
  </span>
564
559
  <span className="mr-seat-check" aria-hidden>
565
- {selected ? <IconCheckOutline16 /> : null}
560
+ {selected ? <CheckIcon /> : null}
566
561
  </span>
567
562
  </button>
568
563
  )
@@ -603,7 +598,7 @@ export function ModelRouterSeat({
603
598
  )}
604
599
  </span>
605
600
  <span className="mr-seat-check" aria-hidden>
606
- {effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
601
+ {effectiveEffort === level.effort ? <CheckIcon /> : null}
607
602
  </span>
608
603
  </button>
609
604
  ))}
@@ -615,7 +610,7 @@ export function ModelRouterSeat({
615
610
  <Toast
616
611
  key={toast.seq}
617
612
  text={toast.text}
618
- icon={<IconWarningOutline16 />}
613
+ icon={<WarningIcon />}
619
614
  anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
620
615
  onDone={() => setToast(null)}
621
616
  />
@@ -654,12 +649,19 @@ interface SeatScopeFace {
654
649
  slots: SlotsFace
655
650
  }
656
651
 
652
+ /**
653
+ * Idle directory snapshot for the inert seat face. Mirrors the shape the
654
+ * official `ModelDirectory` store starts from, including `pending` (the
655
+ * selection an in-flight `select` submitted; null when none) — required by
656
+ * `ModelDirectoryState` and present in both 0.1.x and 0.2.x.
657
+ */
657
658
  const EMPTY_DIRECTORY_STATE: ModelDirectoryState = {
658
659
  current: null,
659
660
  routable: null,
660
661
  groups: [],
661
662
  failures: [],
662
663
  status: 'idle',
664
+ pending: null,
663
665
  error: null,
664
666
  }
665
667
 
@@ -6,14 +6,14 @@
6
6
  * page draws its own static header (title plus a one-line summary) over the
7
7
  * router controls: every routed model with its providers, their order and the
8
8
  * provider the model currently routes to. All reads/mutations go through the
9
- * plugin's fenced API; the server persists through the `model-router` settings
10
- * namespace, so a switch here is live for the next request globally.
9
+ * plugin's fenced API; the server persists into this plugin's loader-entry
10
+ * config (dsh >= 0.1.7), so a switch here is live for the next request
11
+ * globally.
11
12
  *
12
- * The bound settings scope (the same `model-router` namespace the host half
13
- * registers) supplies the page's dispatch state: while the namespace is merely
14
- * loading the page stays mounted, and when it is unavailable (deployment
15
- * without the host half) nothing renders. A read-only deployment shows the
16
- * banner and disables the mutation controls.
13
+ * The page needs no settings service: whether the deployment can persist is
14
+ * reported by the API itself (`state.writable`), so the page registers and
15
+ * mounts everywhere the host half serves its API. A read-only deployment
16
+ * shows the banner and disables the mutation controls.
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
19
19
  import type { Context } from '@deepseek-ai/cordis'
@@ -23,20 +23,6 @@ import { LOCALE_NS } from './locales'
23
23
  import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
24
24
  import './settings-section.css'
25
25
 
26
- /** Client settings scope face (subset of the app client modules). */
27
- export interface SettingsScopeFace {
28
- getSnapshot(): {
29
- status: 'loading' | 'ready' | 'unavailable'
30
- writable: boolean
31
- }
32
- subscribe(listener: () => void): () => void
33
- }
34
-
35
- export interface SettingsSectionProps {
36
- /** The bound `model-router` settings scope (from the slot entry's inject face). */
37
- scope: SettingsScopeFace
38
- }
39
-
40
26
  function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
41
27
  const next = [...list]
42
28
  const [item] = next.splice(from, 1)
@@ -65,7 +51,7 @@ function useLocaleRevision(ctx: ClientContext): number {
65
51
  return useSyncExternalStore(subscribe, getSnapshot)
66
52
  }
67
53
 
68
- export function makeSettingsSection(ctx: ClientContext): (props: SettingsSectionProps) => JSX.Element | null {
54
+ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
69
55
  // Bound translation is namespace-typed; the page's props use the plain
70
56
  // Translate face (string keys), which the dict satisfies structurally.
71
57
  const t: Translate = (() => {
@@ -76,14 +62,7 @@ export function makeSettingsSection(ctx: ClientContext): (props: SettingsSection
76
62
  }
77
63
  })()
78
64
 
79
- return function ModelRouterSettingsSection(props: SettingsSectionProps): JSX.Element | null {
80
- const { scope } = props
81
- // Bind the methods: React invokes getSnapshot/subscribe as bare functions,
82
- // and SettingsScopeController's methods depend on `this`.
83
- const snapshot = useSyncExternalStore(
84
- (listener) => scope.subscribe(listener),
85
- () => scope.getSnapshot(),
86
- )
65
+ return function ModelRouterSettingsSection(): JSX.Element {
87
66
  useLocaleRevision(ctx)
88
67
  const [state, setState] = useState<ModelRouterState | null>(null)
89
68
  const [error, setError] = useState<string | null>(null)
@@ -98,7 +77,8 @@ export function makeSettingsSection(ctx: ClientContext): (props: SettingsSection
98
77
  const load = useCallback(async () => {
99
78
  setLoading(true)
100
79
  try {
101
- setState(await api.state())
80
+ const next = await api.state()
81
+ setState(next)
102
82
  setError(null)
103
83
  } catch (cause) {
104
84
  setError(cause instanceof Error ? cause.message : String(cause))
@@ -213,12 +193,11 @@ export function makeSettingsSection(ctx: ClientContext): (props: SettingsSection
213
193
  }
214
194
  }, [state, t])
215
195
 
216
- // The namespace is served by the host once the scope is ready. While it is
217
- // merely loading, keep the page mounted (the sidebar already dispatched
218
- // it); if it is unavailable (deployment without the host half), render
219
- // nothing.
220
- if (snapshot.status === 'unavailable') return null
221
- const writable = snapshot.writable
196
+ // Persistence capability is reported by the host half through the API
197
+ // itself. A host that predates the field reads as writable: the page must
198
+ // not block edits (with a wrong "read-only" banner) merely because the
199
+ // host half and the client bundle are briefly out of step.
200
+ const writable = state?.writable ?? true
222
201
 
223
202
  const models = useMemo(() => state?.models ?? [], [state])
224
203
 
@@ -229,7 +208,7 @@ export function makeSettingsSection(ctx: ClientContext): (props: SettingsSection
229
208
  <p className="mr-page-sub">{t('sectionSub')}</p>
230
209
  </header>
231
210
  <div className="mr-body">
232
- {!writable && (
211
+ {!loading && state !== null && !writable && (
233
212
  <p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
234
213
  )}
235
214
  <div className="mr-root">
package/src/index.ts CHANGED
@@ -3,14 +3,15 @@
3
3
  *
4
4
  * Registers one `model-router` LLM adapter that aggregates every configured
5
5
  * provider and model (strict model-id merge), routes each request to the
6
- * model's currently active provider by in-process delegation, persists the
7
- * per-model provider order/active config in the `model-router` settings
8
- * namespace, and serves the fenced JSON API the web settings page calls.
6
+ * model's currently active provider by in-process delegation, keeps the
7
+ * per-model provider order/active config in the entry config of this bundle
8
+ * (dsh >= 0.1.7: the `Config` schema below is the settings document), and
9
+ * serves the fenced JSON API the web settings page calls.
9
10
  */
10
11
  import type { Context } from '@deepseek-ai/cordis'
11
- import z from '@deepseek-ai/schemastery'
12
- import { ModelRouterService } from './server/service.js'
12
+ import { ModelRouterService, RouterConfigSchema } from './server/service.js'
13
13
  import { registerApiRoutes } from './server/rpc.js'
14
+ import type { RouterConfigShape } from './shared/config.js'
14
15
 
15
16
  export const name = '@fonlan/dsh-model-router'
16
17
 
@@ -20,10 +21,12 @@ export const name = '@fonlan/dsh-model-router'
20
21
  // no settings page).
21
22
  export const inject = ['llm']
22
23
 
23
- export const Config = z.object({})
24
+ // The entry config IS the router document: the settings plane writes it
25
+ // (settings page / settings.replace) and a changed config restarts this entry.
26
+ export const Config = RouterConfigSchema
24
27
 
25
- export function apply(ctx: Context): void {
26
- const service = new ModelRouterService(ctx)
28
+ export function apply(ctx: Context, config: RouterConfigShape): void {
29
+ const service = new ModelRouterService(ctx, config)
27
30
 
28
31
  ctx.effect(() => {
29
32
  service.start()
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import { credentialRef, type CredentialProvider } from '@deepseek-ai/dsh-credentials'
10
- import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
10
+ import type { SettingsDescriptor } from '@deepseek-ai/dsh-settings'
11
11
  import { mergeModels, ROUTER_PROVIDER_ID, type MergedModel, type ProviderInfo } from '../shared/config.js'
12
12
 
13
13
  /** Live router catalog: everything the router can advertise and route to. */
@@ -39,10 +39,21 @@ interface ConfigurableEntry {
39
39
  settingsPath: readonly string[]
40
40
  }
41
41
 
42
- /** Read the resolved value of another plugin's settings section, if registered. */
42
+ /**
43
+ * Read the resolved value of another plugin's settings section, if the
44
+ * settings plane describes it. The descriptor's `ns` is the profile entry id,
45
+ * so the value is that entry's resolved Config (defaults → composition base →
46
+ * user layer). `describe()` lists only entries that declare volatile
47
+ * (form-writable) fields, and redaction is off here because the caller reads a
48
+ * non-secret field (`apiKeyEnv`). An undescribed section, or an unavailable
49
+ * settings service, yields undefined.
50
+ */
43
51
  function settingsSection(ctx: Context, ns: string): unknown {
44
52
  try {
45
- return (ctx.settings as SettingsProvider).get(ns as never)
53
+ const descriptor: SettingsDescriptor | undefined = ctx.settings
54
+ .describe({ redactSecrets: false })
55
+ .find(candidate => candidate.ns === ns)
56
+ return descriptor?.value
46
57
  } catch {
47
58
  return undefined
48
59
  }
@@ -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,57 @@ import { ModelRouterAdapter, type RouterFacts } from './adapter.js'
25
24
 
26
25
  export const NS = ROUTER_SETTINGS_NS
27
26
 
28
- /** Settings document schema: one entry per model id. */
29
- export const RouterConfigSchema: z<RouterConfigShape> = z.object({
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
+
37
+ /**
38
+ * Settings document schema: one entry per model id.
39
+ *
40
+ * Every writable field stays `.volatile()`: the settings plane refuses a
41
+ * namespace whose form has no volatile field, and `settings.replace` refuses
42
+ * non-volatile paths. The exported schema is nevertheless ASSERTED to the
43
+ * plain `z<RouterConfigShape>` (the declared type it had before the
44
+ * schemastery bump) for two reasons:
45
+ * - since schemastery 3.18.3 a `.volatile()` field's output types as
46
+ * `Volatile<T>`, so `: z<RouterConfigShape>` no longer *compiles*, while the
47
+ * inferred schema type cannot be named in the emitted declarations either
48
+ * (TS2742: it would need a pnpm-nested cosmokit type, and `pnpm build` runs
49
+ * tsc in declaration mode);
50
+ * - the plain shape is what consumers actually observe: the loader hands the
51
+ * fields over as live `{ get() }` cells and `normalizeConfig` unwinds them.
52
+ * The assertion is type-level only — the runtime schema keeps its volatile
53
+ * markers, which is what the settings plane reads.
54
+ */
55
+ export const RouterConfigSchema = z.object({
56
+ // Volatile marks what the settings plane may write: the settings page and
57
+ // settings.replace both refuse non-volatile fields.
30
58
  models: z.dict(z.object({
31
59
  order: z.array(z.string()),
32
60
  active: z.string(),
33
- })),
61
+ })).volatile(),
34
62
  // Older documents predate the toggles; normalizeConfig defaults both on.
35
63
  // 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()),
41
- })
42
-
43
- const EMPTY_CONFIG: RouterConfigShape = {
44
- models: {},
45
- showQuickSwitch: true,
46
- ignoreModelIdPrefix: true,
47
- modelSort: 'custom',
48
- modelOrder: [],
49
- recentlyUsed: {},
50
- }
64
+ showQuickSwitch: z.boolean().volatile(),
65
+ ignoreModelIdPrefix: z.boolean().volatile(),
66
+ modelSort: z.union([z.const('custom'), z.const('name'), z.const('recent')]).volatile(),
67
+ modelOrder: z.array(z.string()).volatile(),
68
+ recentlyUsed: z.dict(z.number()).volatile(),
69
+ }) as unknown as z<RouterConfigShape>
51
70
 
52
71
  /** The settings-page state view served by the API. */
53
72
  export interface ModelRouterState {
73
+ /**
74
+ * Whether this deployment can persist configuration. False (a profile
75
+ * without the host settings service) makes the page render read-only.
76
+ */
77
+ writable: boolean
54
78
  /** Whether provider switching is shown inside the composer model picker menu. */
55
79
  showQuickSwitch: boolean
56
80
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
@@ -83,28 +107,38 @@ export class ModelRouterService implements RouterFacts {
83
107
  }
84
108
  private inflight: Promise<RouterCatalog> | null = null
85
109
  private cachedAt = 0
86
- private scope: SettingsScope<RouterConfigShape> | undefined
110
+ /** Optional host settings service; absent in profiles without one (no persistence). */
111
+ private settings: SettingsReplaceFace | undefined
112
+ /** Live config document (seeded from the entry config, updated on write). */
113
+ private configDoc: RouterConfigShape
87
114
  private registration: AdapterRegistrationHandle | undefined
88
115
 
89
116
  /** How long a built catalog is considered fresh before being rebuilt. */
90
117
  private readonly catalogTtlMs = 30_000
91
118
 
92
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
93
- private readonly usageFlushMs = 1_500
119
+ /**
120
+ * Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
121
+ * long on purpose: every persist writes the entry config, which restarts
122
+ * this plugin's entry, so usage bookkeeping must not stream live traffic.
123
+ */
124
+ private readonly usageFlushMs = 60_000
94
125
  private usageDebounce: ReturnType<typeof setTimeout> | null = null
95
126
  private pendingUsage: Record<string, number> = {}
96
127
  private usageFlushRunning = false
97
128
 
98
- constructor(ctx: Context) {
129
+ constructor(ctx: Context, config: RouterConfigShape) {
99
130
  this.ctx = ctx
131
+ this.configDoc = normalizeConfig(config)
100
132
  }
101
133
 
102
- /** Register settings + adapter and wire topology listeners. */
134
+ /** Register the settings seam + adapter and wire topology listeners. */
103
135
  start(): void {
104
136
  // Optional settings service: without one the router still routes
105
- // (config falls back to catalog order) but cannot persist.
137
+ // (config falls back to catalog order) but cannot persist. The service's
138
+ // shape is only used through SettingsReplaceFace; there is no namespace
139
+ // registration to make on dsh >= 0.1.7.
106
140
  this.ctx.inject(['settings'], (sctx) => {
107
- this.scope = sctx.settings.register(NS, RouterConfigSchema) as SettingsScope<RouterConfigShape>
141
+ this.settings = (sctx as unknown as { settings?: SettingsReplaceFace }).settings
108
142
  })
109
143
 
110
144
  const adapter = new ModelRouterAdapter(this)
@@ -121,14 +155,17 @@ export class ModelRouterService implements RouterFacts {
121
155
  }
122
156
 
123
157
  stop(): void {
124
- // Flush any pending recent-use writes before the scope is torn down.
158
+ // Drop pending recent-use bookkeeping instead of flushing it: on dsh >=
159
+ // 0.1.7 every persist is an entry-config write, which reloads this plugin
160
+ // (teardown included), so writing from stop() would re-enter the loader
161
+ // while it is tearing this very entry down. The timestamps are best-effort
162
+ // ordering hints for the `recent` display mode, so losing the last
163
+ // debounce window is harmless.
125
164
  if (this.usageDebounce !== null) {
126
165
  clearTimeout(this.usageDebounce)
127
166
  this.usageDebounce = null
128
167
  }
129
- if (Object.keys(this.pendingUsage).length > 0) {
130
- void this.flushUsage()
131
- }
168
+ this.pendingUsage = {}
132
169
  this.registration?.()
133
170
  this.registration = undefined
134
171
  }
@@ -140,12 +177,7 @@ export class ModelRouterService implements RouterFacts {
140
177
  }
141
178
 
142
179
  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
- }
180
+ return this.configDoc
149
181
  }
150
182
 
151
183
  providerNames(): ReadonlyMap<string, string> {
@@ -163,7 +195,7 @@ export class ModelRouterService implements RouterFacts {
163
195
  * write.
164
196
  */
165
197
  noteModelUsed(modelId: string): void {
166
- if (this.scope === undefined) return
198
+ if (this.settings === undefined) return
167
199
  this.pendingUsage[modelId] = Date.now()
168
200
  if (this.usageDebounce !== null) return
169
201
  this.usageDebounce = setTimeout(() => {
@@ -179,9 +211,9 @@ export class ModelRouterService implements RouterFacts {
179
211
  const pending = this.pendingUsage
180
212
  this.pendingUsage = {}
181
213
  try {
182
- if (this.scope === undefined || Object.keys(pending).length === 0) return
214
+ if (this.settings === undefined || Object.keys(pending).length === 0) return
183
215
  const current = this.config()
184
- await this.scope.replace({
216
+ await this.writeConfig({
185
217
  ...current,
186
218
  recentlyUsed: { ...current.recentlyUsed, ...pending },
187
219
  })
@@ -233,12 +265,12 @@ export class ModelRouterService implements RouterFacts {
233
265
  /** Refresh (forced), then persist any config drift (migration, new/vanished models). */
234
266
  async refreshAndReconcile(): Promise<void> {
235
267
  const catalog = await this.refreshCatalog(true)
236
- if (this.scope === undefined) return
268
+ if (this.settings === undefined) return
237
269
  const current = this.config()
238
270
  const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
239
271
  if (!changed) return
240
272
  try {
241
- await this.scope.replace({
273
+ await this.writeConfig({
242
274
  ...current,
243
275
  models,
244
276
  ...(modelOrder === undefined ? {} : { modelOrder }),
@@ -264,6 +296,7 @@ export class ModelRouterService implements RouterFacts {
264
296
  const names = catalog.providerNames
265
297
  const creds = catalog.credentialConfigured
266
298
  return {
299
+ writable: this.settings !== undefined,
267
300
  showQuickSwitch: config.showQuickSwitch,
268
301
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
269
302
  modelSort: config.modelSort,
@@ -319,12 +352,9 @@ export class ModelRouterService implements RouterFacts {
319
352
 
320
353
  /** Toggle the composer quick route-switcher button (global, persisted). */
321
354
  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
355
  const current = this.config()
326
356
  if (current.showQuickSwitch === value) return
327
- await this.scope.replace({ ...current, showQuickSwitch: value })
357
+ await this.writeConfig({ ...current, showQuickSwitch: value })
328
358
  }
329
359
 
330
360
  /**
@@ -334,23 +364,17 @@ export class ModelRouterService implements RouterFacts {
334
364
  * turning on; split models re-initialize when turning off).
335
365
  */
336
366
  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
367
  const current = this.config()
341
368
  if (current.ignoreModelIdPrefix === value) return
342
- await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
369
+ await this.writeConfig({ ...current, ignoreModelIdPrefix: value })
343
370
  await this.refreshAndReconcile()
344
371
  }
345
372
 
346
373
  /** Switch the model-list display order mode (global, persisted). */
347
374
  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
375
  const current = this.config()
352
376
  if (current.modelSort === mode) return
353
- await this.scope.replace({ ...current, modelSort: mode })
377
+ await this.writeConfig({ ...current, modelSort: mode })
354
378
  }
355
379
 
356
380
  /**
@@ -359,9 +383,6 @@ export class ModelRouterService implements RouterFacts {
359
383
  * always covers the live catalog.
360
384
  */
361
385
  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
386
  await this.refreshCatalog()
366
387
  const ids = new Set(this.cached.models.map(model => model.id))
367
388
  const clean = order.filter(id => ids.has(id))
@@ -372,14 +393,20 @@ export class ModelRouterService implements RouterFacts {
372
393
  const sameOrder = clean.length === current.modelOrder.length
373
394
  && clean.every((id, at) => id === current.modelOrder[at])
374
395
  if (sameOrder) return
375
- await this.scope.replace({ ...current, modelOrder: clean })
396
+ await this.writeConfig({ ...current, modelOrder: clean })
376
397
  }
377
398
 
378
- private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
379
- if (this.scope === undefined) {
399
+ /** Write the document through the host settings service (entry config). */
400
+ private async writeConfig(next: RouterConfigShape): Promise<void> {
401
+ if (this.settings === undefined) {
380
402
  throw new Error('model-router: settings service is not available in this profile')
381
403
  }
404
+ await this.settings.replace(NS, next, undefined)
405
+ this.configDoc = normalizeConfig(next)
406
+ }
407
+
408
+ private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
382
409
  const current = this.config()
383
- await this.scope.replace({ ...current, models })
410
+ await this.writeConfig({ ...current, models })
384
411
  }
385
412
  }