@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.
- package/lib/client.js +158 -88
- package/lib/client.js.map +1 -1
- package/lib/index.js +70 -47
- package/lib/types/index.d.ts +7 -6
- package/lib/types/server/service.d.ts +27 -4
- package/lib/types/shared/config.d.ts +2 -0
- package/package.json +8 -8
- package/src/client/api.ts +5 -0
- package/src/client/icons.tsx +97 -0
- package/src/client/index.tsx +45 -28
- package/src/client/locales.ts +3 -7
- package/src/client/model-seat.tsx +10 -15
- package/src/client/{settings-card.css → settings-section.css} +19 -59
- package/src/client/settings-section.tsx +431 -0
- package/src/index.ts +11 -8
- package/src/server/service.ts +67 -57
- package/src/shared/config.ts +32 -2
- package/src/client/settings-card.tsx +0 -469
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,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
|
-
|
|
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
|
-
/**
|
|
93
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
197
|
+
if (this.settings === undefined || Object.keys(pending).length === 0) return
|
|
183
198
|
const current = this.config()
|
|
184
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
379
|
+
await this.writeConfig({ ...current, modelOrder: clean })
|
|
376
380
|
}
|
|
377
381
|
|
|
378
|
-
|
|
379
|
-
|
|
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.
|
|
393
|
+
await this.writeConfig({ ...current, models })
|
|
384
394
|
}
|
|
385
395
|
}
|
package/src/shared/config.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
502
|
+
const root = source as {
|
|
473
503
|
models?: unknown
|
|
474
504
|
showQuickSwitch?: unknown
|
|
475
505
|
ignoreModelIdPrefix?: unknown
|