@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.
- package/lib/client.js +131 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +98 -50
- package/lib/types/index.d.ts +7 -6
- package/lib/types/server/service.d.ts +45 -5
- package/lib/types/shared/config.d.ts +2 -0
- package/package.json +25 -25
- package/src/client/api.ts +5 -0
- package/src/client/icons.tsx +97 -0
- package/src/client/index.tsx +15 -20
- package/src/client/model-seat.tsx +17 -15
- package/src/client/settings-section.tsx +17 -38
- package/src/index.ts +11 -8
- package/src/server/catalog.ts +14 -3
- package/src/server/service.ts +87 -60
- package/src/shared/config.ts +32 -2
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
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).
|
|
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', '
|
|
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.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 ? <
|
|
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 ? <
|
|
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 ? <
|
|
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={<
|
|
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
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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): (
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
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,
|
|
7
|
-
* per-model provider order/active config in the
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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()
|
package/src/server/catalog.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/server/service.ts
CHANGED
|
@@ -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
|
-
/**
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
93
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
214
|
+
if (this.settings === undefined || Object.keys(pending).length === 0) return
|
|
183
215
|
const current = this.config()
|
|
184
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
396
|
+
await this.writeConfig({ ...current, modelOrder: clean })
|
|
376
397
|
}
|
|
377
398
|
|
|
378
|
-
|
|
379
|
-
|
|
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.
|
|
410
|
+
await this.writeConfig({ ...current, models })
|
|
384
411
|
}
|
|
385
412
|
}
|