@fonlan/dsh-model-router 0.3.0 → 0.3.3
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/README.md +17 -10
- package/lib/client.js +844 -239
- package/lib/client.js.map +1 -1
- package/lib/index.js +257 -30
- package/lib/types/server/adapter.d.ts +27 -1
- package/lib/types/server/service.d.ts +45 -5
- package/lib/types/shared/config.d.ts +54 -2
- package/package.json +33 -29
- package/src/client/api.ts +11 -1
- package/src/client/index.tsx +39 -16
- package/src/client/locales.ts +64 -14
- package/src/client/model-seat.css +289 -0
- package/src/client/model-seat.tsx +623 -0
- package/src/client/settings-card.css +470 -0
- package/src/client/settings-card.tsx +468 -0
- package/src/server/adapter.ts +62 -13
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +135 -25
- package/src/shared/config.ts +142 -8
- package/src/client/route-switcher.css +0 -136
- package/src/client/route-switcher.tsx +0 -252
- package/src/client/settings-section.css +0 -245
- package/src/client/settings-section.tsx +0 -293
package/src/server/service.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
resolveActive,
|
|
17
17
|
setActive,
|
|
18
18
|
setOrder,
|
|
19
|
+
sortModelsForList,
|
|
20
|
+
type ModelSortMode,
|
|
19
21
|
type RouterConfigShape,
|
|
20
22
|
} from '../shared/config.js'
|
|
21
23
|
import { buildCatalog, type RouterCatalog } from './catalog.js'
|
|
@@ -33,16 +35,30 @@ export const RouterConfigSchema: z<RouterConfigShape> = z.object({
|
|
|
33
35
|
// Schemastery object keys are input-optional, so absent is valid here.
|
|
34
36
|
showQuickSwitch: z.boolean(),
|
|
35
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()),
|
|
36
41
|
})
|
|
37
42
|
|
|
38
|
-
const EMPTY_CONFIG: RouterConfigShape = {
|
|
43
|
+
const EMPTY_CONFIG: RouterConfigShape = {
|
|
44
|
+
models: {},
|
|
45
|
+
showQuickSwitch: true,
|
|
46
|
+
ignoreModelIdPrefix: true,
|
|
47
|
+
modelSort: 'custom',
|
|
48
|
+
modelOrder: [],
|
|
49
|
+
recentlyUsed: {},
|
|
50
|
+
}
|
|
39
51
|
|
|
40
52
|
/** The settings-page state view served by the API. */
|
|
41
53
|
export interface ModelRouterState {
|
|
42
|
-
/** Whether the composer
|
|
54
|
+
/** Whether provider switching is shown inside the composer model picker menu. */
|
|
43
55
|
showQuickSwitch: boolean
|
|
44
56
|
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
45
57
|
ignoreModelIdPrefix: boolean
|
|
58
|
+
/** The model-list display order mode (custom | name | recent). */
|
|
59
|
+
modelSort: ModelSortMode
|
|
60
|
+
/** Explicit model id order for custom mode (display order). */
|
|
61
|
+
modelOrder: string[]
|
|
46
62
|
providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
|
|
47
63
|
models: Array<{
|
|
48
64
|
id: string
|
|
@@ -66,9 +82,19 @@ export class ModelRouterService implements RouterFacts {
|
|
|
66
82
|
credentialConfigured: new Map(),
|
|
67
83
|
}
|
|
68
84
|
private inflight: Promise<RouterCatalog> | null = null
|
|
85
|
+
private cachedAt = 0
|
|
69
86
|
private scope: SettingsScope<RouterConfigShape> | undefined
|
|
70
87
|
private registration: AdapterRegistrationHandle | undefined
|
|
71
88
|
|
|
89
|
+
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
90
|
+
private readonly catalogTtlMs = 30_000
|
|
91
|
+
|
|
92
|
+
/** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
|
|
93
|
+
private readonly usageFlushMs = 1_500
|
|
94
|
+
private usageDebounce: ReturnType<typeof setTimeout> | null = null
|
|
95
|
+
private pendingUsage: Record<string, number> = {}
|
|
96
|
+
private usageFlushRunning = false
|
|
97
|
+
|
|
72
98
|
constructor(ctx: Context) {
|
|
73
99
|
this.ctx = ctx
|
|
74
100
|
}
|
|
@@ -95,6 +121,14 @@ export class ModelRouterService implements RouterFacts {
|
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
stop(): void {
|
|
124
|
+
// Flush any pending recent-use writes before the scope is torn down.
|
|
125
|
+
if (this.usageDebounce !== null) {
|
|
126
|
+
clearTimeout(this.usageDebounce)
|
|
127
|
+
this.usageDebounce = null
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(this.pendingUsage).length > 0) {
|
|
130
|
+
void this.flushUsage()
|
|
131
|
+
}
|
|
98
132
|
this.registration?.()
|
|
99
133
|
this.registration = undefined
|
|
100
134
|
}
|
|
@@ -122,14 +156,68 @@ export class ModelRouterService implements RouterFacts {
|
|
|
122
156
|
return this.ctx.llm
|
|
123
157
|
}
|
|
124
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Record one model request for the `recent` display order. Fire-and-forget
|
|
161
|
+
* (the adapter never awaits it): timestamps accumulate in memory and are
|
|
162
|
+
* persisted debounced, so a burst of requests coalesces into one settings
|
|
163
|
+
* write.
|
|
164
|
+
*/
|
|
165
|
+
noteModelUsed(modelId: string): void {
|
|
166
|
+
if (this.scope === undefined) return
|
|
167
|
+
this.pendingUsage[modelId] = Date.now()
|
|
168
|
+
if (this.usageDebounce !== null) return
|
|
169
|
+
this.usageDebounce = setTimeout(() => {
|
|
170
|
+
this.usageDebounce = null
|
|
171
|
+
void this.flushUsage()
|
|
172
|
+
}, this.usageFlushMs)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Persist accumulated usage timestamps (merging with any concurrent writes). */
|
|
176
|
+
private async flushUsage(): Promise<void> {
|
|
177
|
+
if (this.usageFlushRunning) return
|
|
178
|
+
this.usageFlushRunning = true
|
|
179
|
+
const pending = this.pendingUsage
|
|
180
|
+
this.pendingUsage = {}
|
|
181
|
+
try {
|
|
182
|
+
if (this.scope === undefined || Object.keys(pending).length === 0) return
|
|
183
|
+
const current = this.config()
|
|
184
|
+
await this.scope.replace({
|
|
185
|
+
...current,
|
|
186
|
+
recentlyUsed: { ...current.recentlyUsed, ...pending },
|
|
187
|
+
})
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Re-queue the failed batch so a transient write failure does not lose
|
|
190
|
+
// usage history; the next request retries it.
|
|
191
|
+
this.pendingUsage = { ...pending, ...this.pendingUsage }
|
|
192
|
+
this.ctx.logger.warn(`model-router: usage persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
193
|
+
} finally {
|
|
194
|
+
this.usageFlushRunning = false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
125
198
|
// ── catalog lifecycle ────────────────────────────────────────────────────
|
|
126
199
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Return the live catalog, rebuilding it only when it is stale (older than
|
|
202
|
+
* `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
|
|
203
|
+
* build; a failed build keeps the last good catalog.
|
|
204
|
+
*
|
|
205
|
+
* The TTL is what keeps the composer quick-switcher snappy: every
|
|
206
|
+
* `state()` RPC goes through here, and without a cache each one rebuilt the
|
|
207
|
+
* catalog — including per-provider `listModels` network calls — which is
|
|
208
|
+
* what made the button gray out (routerLoading) for the whole duration.
|
|
209
|
+
* Topology events (`llm/adapters-updated`) and explicit user refreshes pass
|
|
210
|
+
* `force` so the cache never hides real changes.
|
|
211
|
+
*/
|
|
212
|
+
refreshCatalog(force = false): Promise<RouterCatalog> {
|
|
213
|
+
if (!force && this.inflight === null && Date.now() - this.cachedAt < this.catalogTtlMs) {
|
|
214
|
+
return Promise.resolve(this.cached)
|
|
215
|
+
}
|
|
129
216
|
if (this.inflight !== null) return this.inflight
|
|
130
217
|
this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix)
|
|
131
218
|
.then((catalog) => {
|
|
132
219
|
this.cached = catalog
|
|
220
|
+
this.cachedAt = Date.now()
|
|
133
221
|
return catalog
|
|
134
222
|
})
|
|
135
223
|
.catch((error) => {
|
|
@@ -142,18 +230,18 @@ export class ModelRouterService implements RouterFacts {
|
|
|
142
230
|
return this.inflight
|
|
143
231
|
}
|
|
144
232
|
|
|
145
|
-
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
233
|
+
/** Refresh (forced), then persist any config drift (migration, new/vanished models). */
|
|
146
234
|
async refreshAndReconcile(): Promise<void> {
|
|
147
|
-
const catalog = await this.refreshCatalog()
|
|
235
|
+
const catalog = await this.refreshCatalog(true)
|
|
148
236
|
if (this.scope === undefined) return
|
|
149
237
|
const current = this.config()
|
|
150
|
-
const { models, changed } = reconcileConfig(current, catalog.models)
|
|
238
|
+
const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
|
|
151
239
|
if (!changed) return
|
|
152
240
|
try {
|
|
153
241
|
await this.scope.replace({
|
|
242
|
+
...current,
|
|
154
243
|
models,
|
|
155
|
-
|
|
156
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
244
|
+
...(modelOrder === undefined ? {} : { modelOrder }),
|
|
157
245
|
})
|
|
158
246
|
} catch (error) {
|
|
159
247
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -178,12 +266,14 @@ export class ModelRouterService implements RouterFacts {
|
|
|
178
266
|
return {
|
|
179
267
|
showQuickSwitch: config.showQuickSwitch,
|
|
180
268
|
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
269
|
+
modelSort: config.modelSort,
|
|
270
|
+
modelOrder: config.modelOrder,
|
|
181
271
|
providers: catalog.providers.map(provider => ({
|
|
182
272
|
id: provider.id,
|
|
183
273
|
name: names.get(provider.id) ?? provider.id,
|
|
184
274
|
credentialConfigured: creds.get(provider.id) ?? true,
|
|
185
275
|
})),
|
|
186
|
-
models: catalog.models.map((model) => {
|
|
276
|
+
models: sortModelsForList(catalog.models, config).map((model) => {
|
|
187
277
|
const ids = model.providers.map(provider => provider.provider)
|
|
188
278
|
const stored = config.models[model.id] ?? initialConfigFor(ids)
|
|
189
279
|
const active = resolveActive(config, model.id, ids)
|
|
@@ -234,11 +324,7 @@ export class ModelRouterService implements RouterFacts {
|
|
|
234
324
|
}
|
|
235
325
|
const current = this.config()
|
|
236
326
|
if (current.showQuickSwitch === value) return
|
|
237
|
-
await this.scope.replace({
|
|
238
|
-
models: current.models,
|
|
239
|
-
showQuickSwitch: value,
|
|
240
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
241
|
-
})
|
|
327
|
+
await this.scope.replace({ ...current, showQuickSwitch: value })
|
|
242
328
|
}
|
|
243
329
|
|
|
244
330
|
/**
|
|
@@ -253,23 +339,47 @@ export class ModelRouterService implements RouterFacts {
|
|
|
253
339
|
}
|
|
254
340
|
const current = this.config()
|
|
255
341
|
if (current.ignoreModelIdPrefix === value) return
|
|
256
|
-
await this.scope.replace({
|
|
257
|
-
models: current.models,
|
|
258
|
-
showQuickSwitch: current.showQuickSwitch,
|
|
259
|
-
ignoreModelIdPrefix: value,
|
|
260
|
-
})
|
|
342
|
+
await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
|
|
261
343
|
await this.refreshAndReconcile()
|
|
262
344
|
}
|
|
263
345
|
|
|
346
|
+
/** Switch the model-list display order mode (global, persisted). */
|
|
347
|
+
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
|
+
const current = this.config()
|
|
352
|
+
if (current.modelSort === mode) return
|
|
353
|
+
await this.scope.replace({ ...current, modelSort: mode })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Replace the explicit custom model order (global, persisted). Vanished
|
|
358
|
+
* ids are pruned and missing catalog models appended, so the stored order
|
|
359
|
+
* always covers the live catalog.
|
|
360
|
+
*/
|
|
361
|
+
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
|
+
await this.refreshCatalog()
|
|
366
|
+
const ids = new Set(this.cached.models.map(model => model.id))
|
|
367
|
+
const clean = order.filter(id => ids.has(id))
|
|
368
|
+
for (const model of this.cached.models) {
|
|
369
|
+
if (!clean.includes(model.id)) clean.push(model.id)
|
|
370
|
+
}
|
|
371
|
+
const current = this.config()
|
|
372
|
+
const sameOrder = clean.length === current.modelOrder.length
|
|
373
|
+
&& clean.every((id, at) => id === current.modelOrder[at])
|
|
374
|
+
if (sameOrder) return
|
|
375
|
+
await this.scope.replace({ ...current, modelOrder: clean })
|
|
376
|
+
}
|
|
377
|
+
|
|
264
378
|
private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
|
|
265
379
|
if (this.scope === undefined) {
|
|
266
380
|
throw new Error('model-router: settings service is not available in this profile')
|
|
267
381
|
}
|
|
268
382
|
const current = this.config()
|
|
269
|
-
await this.scope.replace({
|
|
270
|
-
models,
|
|
271
|
-
showQuickSwitch: current.showQuickSwitch,
|
|
272
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
273
|
-
})
|
|
383
|
+
await this.scope.replace({ ...current, models })
|
|
274
384
|
}
|
|
275
385
|
}
|
package/src/shared/config.ts
CHANGED
|
@@ -64,12 +64,28 @@ export interface RouterModelConfig {
|
|
|
64
64
|
active: string
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* How the router orders the model list it advertises to the host model
|
|
69
|
+
* picker (the `model-router` group in DSH's model selection box).
|
|
70
|
+
*
|
|
71
|
+
* - `custom`: the explicit `modelOrder` array wins; models absent from it are
|
|
72
|
+
* appended in catalog (first-seen) order.
|
|
73
|
+
* - `name`: models sorted by their display name (active provider's name),
|
|
74
|
+
* falling back to the merged id.
|
|
75
|
+
* - `recent`: models sorted by most-recently-used first (timestamp desc);
|
|
76
|
+
* never-used models come last in catalog order.
|
|
77
|
+
*
|
|
78
|
+
* Absent/unknown documents normalize to `custom` (the pre-existing catalog
|
|
79
|
+
* order, so upgrading is a no-op until the user changes it).
|
|
80
|
+
*/
|
|
81
|
+
export type ModelSortMode = 'custom' | 'name' | 'recent'
|
|
82
|
+
|
|
67
83
|
/** The whole router config document. */
|
|
68
84
|
export interface RouterConfigShape {
|
|
69
85
|
models: Record<string, RouterModelConfig>
|
|
70
86
|
/**
|
|
71
|
-
* Whether the
|
|
72
|
-
*
|
|
87
|
+
* Whether the provider switching entry is shown inside the composer model
|
|
88
|
+
* picker menu (settings → 模型路由 → 在模型选择菜单中显示提供商切换). Stored
|
|
73
89
|
* globally; absent/unknown documents normalize to `true`.
|
|
74
90
|
*/
|
|
75
91
|
showQuickSwitch: boolean
|
|
@@ -81,6 +97,23 @@ export interface RouterConfigShape {
|
|
|
81
97
|
* `true`.
|
|
82
98
|
*/
|
|
83
99
|
ignoreModelIdPrefix: boolean
|
|
100
|
+
/**
|
|
101
|
+
* The model-list display order mode (settings → 模型路由 → 模型显示顺序).
|
|
102
|
+
* Absent/unknown documents normalize to `custom`.
|
|
103
|
+
*/
|
|
104
|
+
modelSort: ModelSortMode
|
|
105
|
+
/**
|
|
106
|
+
* Explicit model id order for `custom` mode, in display order. Kept
|
|
107
|
+
* reconciled against the catalog (vanished ids pruned, new ids appended).
|
|
108
|
+
* Absent/unknown documents normalize to `[]`.
|
|
109
|
+
*/
|
|
110
|
+
modelOrder: string[]
|
|
111
|
+
/**
|
|
112
|
+
* Merged model id → last-use epoch millis, for `recent` mode. Written by
|
|
113
|
+
* the router adapter whenever a model request streams. Absent/unknown
|
|
114
|
+
* documents normalize to `{}`.
|
|
115
|
+
*/
|
|
116
|
+
recentlyUsed: Record<string, number>
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
export const ROUTER_PROVIDER_ID = 'model-router'
|
|
@@ -152,6 +185,59 @@ export function displayProvider(
|
|
|
152
185
|
return merged.providers.find(p => p.provider === active) ?? merged.providers[0]
|
|
153
186
|
}
|
|
154
187
|
|
|
188
|
+
/** The display name a merged model advertises (active provider's name). */
|
|
189
|
+
export function displayNameOf(
|
|
190
|
+
model: MergedModel,
|
|
191
|
+
config: RouterConfigShape,
|
|
192
|
+
): string {
|
|
193
|
+
const ids = model.providers.map(provider => provider.provider)
|
|
194
|
+
const active = resolveActive(config, model.id, ids)
|
|
195
|
+
return displayProvider(model, active ?? undefined)?.name ?? model.id
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Order the merged catalog the way the model picker should list it, per the
|
|
200
|
+
* configured `modelSort` mode. Always returns a permutation of `models`
|
|
201
|
+
* (never drops or duplicates entries).
|
|
202
|
+
*
|
|
203
|
+
* - `custom`: `config.modelOrder` first (in stored order), then any model not
|
|
204
|
+
* present in it, in catalog order.
|
|
205
|
+
* - `name`: display-name ascending (numeric-aware, case-insensitive), ties
|
|
206
|
+
* keep catalog order (stable sort).
|
|
207
|
+
* - `recent`: most-recently-used first; never-used models sink to the tail in
|
|
208
|
+
* catalog order. Ties (equal timestamps, or both unused) keep catalog
|
|
209
|
+
* order (stable sort).
|
|
210
|
+
*/
|
|
211
|
+
export function sortModelsForList(
|
|
212
|
+
models: readonly MergedModel[],
|
|
213
|
+
config: RouterConfigShape,
|
|
214
|
+
): MergedModel[] {
|
|
215
|
+
if (config.modelSort === 'name') {
|
|
216
|
+
const byName = new Map(models.map(model => [model, displayNameOf(model, config)]))
|
|
217
|
+
return [...models].sort((left, right) =>
|
|
218
|
+
byName.get(left)!.localeCompare(byName.get(right)!, undefined, { numeric: true, sensitivity: 'base' }),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (config.modelSort === 'recent') {
|
|
222
|
+
const recent = config.recentlyUsed ?? {}
|
|
223
|
+
return [...models].sort((left, right) =>
|
|
224
|
+
(recent[right.id] ?? -Infinity) - (recent[left.id] ?? -Infinity),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
// custom: explicit order first (deduped), then the catalog-order tail
|
|
228
|
+
const listed: MergedModel[] = []
|
|
229
|
+
const seen = new Set<string>()
|
|
230
|
+
for (const id of config.modelOrder ?? []) {
|
|
231
|
+
if (seen.has(id)) continue
|
|
232
|
+
const model = models.find(candidate => candidate.id === id)
|
|
233
|
+
if (model === undefined) continue
|
|
234
|
+
seen.add(id)
|
|
235
|
+
listed.push(model)
|
|
236
|
+
}
|
|
237
|
+
const tail = models.filter(model => !seen.has(model.id))
|
|
238
|
+
return [...listed, ...tail]
|
|
239
|
+
}
|
|
240
|
+
|
|
155
241
|
/** Initial config for a model the user never configured: first in order wins. */
|
|
156
242
|
export function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null {
|
|
157
243
|
if (providerIds.length === 0) return null
|
|
@@ -256,11 +342,14 @@ function sameModels(a: Record<string, RouterModelConfig>, b: Record<string, Rout
|
|
|
256
342
|
* for `active`, and `order` becomes the deduped union in exact-key-first
|
|
257
343
|
* order. Stored keys no longer mapping to any merged model are dropped (this
|
|
258
344
|
* also covers turning the flag off — split models re-initialize fresh).
|
|
345
|
+
*
|
|
346
|
+
* `modelOrder` (custom display order) is reconciled the same way: vanished
|
|
347
|
+
* ids are pruned, newly discovered models are appended in catalog order.
|
|
259
348
|
*/
|
|
260
349
|
export function reconcileConfig(
|
|
261
350
|
config: RouterConfigShape,
|
|
262
351
|
models: readonly MergedModel[],
|
|
263
|
-
): { models: Record<string, RouterModelConfig>; changed: boolean } {
|
|
352
|
+
): { models: Record<string, RouterModelConfig>; modelOrder: string[] | undefined; changed: boolean } {
|
|
264
353
|
// stored key → merged id it belongs to (exact key wins; prefixed raw keys
|
|
265
354
|
// map to their stripped merged id only while the flag is on)
|
|
266
355
|
const keyToMerged = new Map<string, string>()
|
|
@@ -306,7 +395,31 @@ export function reconcileConfig(
|
|
|
306
395
|
if (entry !== undefined) next[model.id] = entry
|
|
307
396
|
}
|
|
308
397
|
|
|
309
|
-
|
|
398
|
+
// Reconcile the custom display order against the live catalog: prune
|
|
399
|
+
// vanished ids, append newly discovered models in catalog order. Documents
|
|
400
|
+
// that predate the field (modelOrder undefined) keep it absent — the
|
|
401
|
+
// service's normalizeConfig materializes it on the next write, and an old
|
|
402
|
+
// document must not report drift merely because the field is missing.
|
|
403
|
+
const storedOrder = config.modelOrder
|
|
404
|
+
let modelOrder: string[] | undefined
|
|
405
|
+
let orderChanged = false
|
|
406
|
+
if (storedOrder !== undefined) {
|
|
407
|
+
const catalogIds = new Set(models.map(model => model.id))
|
|
408
|
+
modelOrder = storedOrder.filter(id => catalogIds.has(id))
|
|
409
|
+
orderChanged = modelOrder.length !== storedOrder.length
|
|
410
|
+
for (const model of models) {
|
|
411
|
+
if (!modelOrder.includes(model.id)) {
|
|
412
|
+
modelOrder.push(model.id)
|
|
413
|
+
orderChanged = true
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
models: next,
|
|
420
|
+
modelOrder,
|
|
421
|
+
changed: !sameModels(next, config.models) || orderChanged,
|
|
422
|
+
}
|
|
310
423
|
}
|
|
311
424
|
|
|
312
425
|
/** Switch a model's active provider (no-op when already active). */
|
|
@@ -354,13 +467,34 @@ export function setOrder(
|
|
|
354
467
|
export function normalizeConfig(value: unknown): RouterConfigShape {
|
|
355
468
|
const models: Record<string, RouterModelConfig> = {}
|
|
356
469
|
if (typeof value !== 'object' || value === null) {
|
|
357
|
-
return { models, showQuickSwitch: true, ignoreModelIdPrefix: true }
|
|
470
|
+
return { models, showQuickSwitch: true, ignoreModelIdPrefix: true, modelSort: 'custom', modelOrder: [], recentlyUsed: {} }
|
|
471
|
+
}
|
|
472
|
+
const root = value as {
|
|
473
|
+
models?: unknown
|
|
474
|
+
showQuickSwitch?: unknown
|
|
475
|
+
ignoreModelIdPrefix?: unknown
|
|
476
|
+
modelSort?: unknown
|
|
477
|
+
modelOrder?: unknown
|
|
478
|
+
recentlyUsed?: unknown
|
|
358
479
|
}
|
|
359
|
-
const root = value as { models?: unknown; showQuickSwitch?: unknown; ignoreModelIdPrefix?: unknown }
|
|
360
480
|
const showQuickSwitch = root.showQuickSwitch !== false
|
|
361
481
|
const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false
|
|
482
|
+
const modelSort: ModelSortMode = root.modelSort === 'name' || root.modelSort === 'recent'
|
|
483
|
+
? root.modelSort
|
|
484
|
+
: 'custom'
|
|
485
|
+
const modelOrder = Array.isArray(root.modelOrder)
|
|
486
|
+
? root.modelOrder.filter((x): x is string => typeof x === 'string')
|
|
487
|
+
: []
|
|
488
|
+
const recentlyUsed: Record<string, number> = {}
|
|
489
|
+
if (typeof root.recentlyUsed === 'object' && root.recentlyUsed !== null) {
|
|
490
|
+
for (const [id, ts] of Object.entries(root.recentlyUsed as Record<string, unknown>)) {
|
|
491
|
+
if (typeof ts === 'number' && Number.isFinite(ts)) recentlyUsed[id] = ts
|
|
492
|
+
}
|
|
493
|
+
}
|
|
362
494
|
const raw = root.models
|
|
363
|
-
if (typeof raw !== 'object' || raw === null)
|
|
495
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
496
|
+
return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
|
|
497
|
+
}
|
|
364
498
|
for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
365
499
|
if (typeof entry !== 'object' || entry === null) continue
|
|
366
500
|
const e = entry as { order?: unknown; active?: unknown }
|
|
@@ -369,5 +503,5 @@ export function normalizeConfig(value: unknown): RouterConfigShape {
|
|
|
369
503
|
if (active === '') continue
|
|
370
504
|
models[id] = { order, active }
|
|
371
505
|
}
|
|
372
|
-
return { models, showQuickSwitch, ignoreModelIdPrefix }
|
|
506
|
+
return { models, showQuickSwitch, ignoreModelIdPrefix, modelSort, modelOrder, recentlyUsed }
|
|
373
507
|
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/* Quick provider route control beside DSH's composer model seat. */
|
|
2
|
-
.mr-route-anchor {
|
|
3
|
-
/* Menu's wrapper span: never let the row crush the trigger. */
|
|
4
|
-
flex: none;
|
|
5
|
-
min-width: 0;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
.mr-route-trigger {
|
|
9
|
-
min-width: 0;
|
|
10
|
-
max-width: 180px;
|
|
11
|
-
height: 28px;
|
|
12
|
-
display: flex;
|
|
13
|
-
align-items: center;
|
|
14
|
-
gap: 2px;
|
|
15
|
-
padding: 0 6px;
|
|
16
|
-
border: none;
|
|
17
|
-
border-radius: 24px;
|
|
18
|
-
color: var(--dsw-alias-label-secondary);
|
|
19
|
-
background: transparent;
|
|
20
|
-
cursor: pointer;
|
|
21
|
-
font: inherit;
|
|
22
|
-
font-size: 12px;
|
|
23
|
-
font-weight: 500;
|
|
24
|
-
line-height: 20px;
|
|
25
|
-
outline: none;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.mr-route-trigger:hover:not(:disabled) {
|
|
29
|
-
background: var(--dsw-alias-interactive-bg-hover);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
.mr-route-trigger:focus-visible {
|
|
33
|
-
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.mr-route-trigger:disabled {
|
|
37
|
-
color: var(--dsw-alias-label-dimmed);
|
|
38
|
-
cursor: default;
|
|
39
|
-
opacity: 0.7;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.mr-route-trigger-label {
|
|
43
|
-
min-width: 0;
|
|
44
|
-
overflow: hidden;
|
|
45
|
-
text-overflow: ellipsis;
|
|
46
|
-
white-space: nowrap;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.mr-route-trigger-chevron {
|
|
50
|
-
color: var(--dsw-alias-label-caption);
|
|
51
|
-
flex: none;
|
|
52
|
-
transition: transform 0.12s;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.mr-route-trigger[aria-expanded="true"] .mr-route-trigger-chevron {
|
|
56
|
-
transform: rotate(180deg);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.mr-route-item-copy {
|
|
60
|
-
min-width: 0;
|
|
61
|
-
display: flex;
|
|
62
|
-
align-items: baseline;
|
|
63
|
-
gap: 6px;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
.mr-route-item-name {
|
|
67
|
-
min-width: 0;
|
|
68
|
-
overflow: hidden;
|
|
69
|
-
text-overflow: ellipsis;
|
|
70
|
-
white-space: nowrap;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.mr-route-item-note {
|
|
74
|
-
flex: none;
|
|
75
|
-
color: var(--dsw-alias-label-tertiary);
|
|
76
|
-
font-size: 11px;
|
|
77
|
-
line-height: 16px;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/*
|
|
81
|
-
* Mobile portrait: the composer row is an inline-size container. Keep the
|
|
82
|
-
* fixed control row intact — attachment, permission, provider (icon-only),
|
|
83
|
-
* context meter, send — and let the model seat absorb the remaining width.
|
|
84
|
-
* The breakpoint matches DSH's own narrow-composer rule (<=460px).
|
|
85
|
-
*/
|
|
86
|
-
@container (width <= 460px) {
|
|
87
|
-
.mr-route-anchor {
|
|
88
|
-
flex: 0 0 auto;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.mr-route-trigger {
|
|
92
|
-
/* The trigger now carries globe + chevron, so let it size naturally
|
|
93
|
-
instead of forcing a 28px circle that could overflow the row. */
|
|
94
|
-
height: 28px;
|
|
95
|
-
padding: 4px 6px;
|
|
96
|
-
justify-content: center;
|
|
97
|
-
border-radius: 999px;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/*
|
|
101
|
-
* Make the composer's trailing group (the parent of our [data-slot] right
|
|
102
|
-
* wrapper) shrinkable on narrow rows so the globe/chevron trigger never
|
|
103
|
-
* spills left over the permission control. The model seat inside it
|
|
104
|
-
* absorbs the compression (flex 1 1 0, min-width 0 below).
|
|
105
|
-
*/
|
|
106
|
-
div:has(> [data-slot="conversation.input.right"]) {
|
|
107
|
-
flex: 1 1 auto;
|
|
108
|
-
min-width: 0;
|
|
109
|
-
justify-content: flex-end;
|
|
110
|
-
/* Mirror the trailing group's internal gap (12px) so the provider
|
|
111
|
-
trigger has equal spacing to the permission button on its left and
|
|
112
|
-
the model seat on its right. */
|
|
113
|
-
margin-left: 12px;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
.mr-route-trigger-label {
|
|
117
|
-
display: none;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/*
|
|
121
|
-
* The model seat: each slot is wrapped in a display:contents
|
|
122
|
-
* [data-slot] div, so the ModelSelect root div (the real flex item of the
|
|
123
|
-
* composer row) is [data-slot="conversation.input.model"] > *. Give it
|
|
124
|
-
* the remaining row width; the other controls keep fixed sizes.
|
|
125
|
-
*/
|
|
126
|
-
[data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] > * {
|
|
127
|
-
flex: 1 1 0;
|
|
128
|
-
min-width: 0;
|
|
129
|
-
max-width: 100%;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
[data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] [aria-haspopup="menu"] {
|
|
133
|
-
width: 100%;
|
|
134
|
-
max-width: 100%;
|
|
135
|
-
}
|
|
136
|
-
}
|