@fonlan/dsh-model-router 0.3.1 → 0.3.4

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.
@@ -327,3 +327,144 @@
327
327
  line-height: 16px;
328
328
  padding: 0 8px;
329
329
  }
330
+
331
+ /* ── model display order ────────────────────────────────────────────────── */
332
+
333
+ .mr-sort {
334
+ display: flex;
335
+ flex-direction: column;
336
+ gap: 8px;
337
+ padding: 8px 10px;
338
+ border: 1px solid var(--dsw-alias-border-inverted);
339
+ border-radius: 10px;
340
+ background: var(--dsw-alias-bg-module-platform);
341
+ }
342
+
343
+ .mr-sort-head {
344
+ display: flex;
345
+ flex-direction: column;
346
+ gap: 2px;
347
+ }
348
+
349
+ .mr-sort-title {
350
+ color: var(--dsw-alias-label-primary);
351
+ font-size: 13px;
352
+ line-height: 20px;
353
+ }
354
+
355
+ .mr-sort-desc {
356
+ color: var(--dsw-alias-label-tertiary);
357
+ font-size: 12px;
358
+ line-height: 18px;
359
+ }
360
+
361
+ .mr-sort-modes {
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 4px;
365
+ }
366
+
367
+ .mr-sort-mode {
368
+ display: flex;
369
+ align-items: flex-start;
370
+ gap: 8px;
371
+ border: 1px solid transparent;
372
+ border-radius: 8px;
373
+ padding: 5px 6px;
374
+ cursor: pointer;
375
+ }
376
+
377
+ .mr-sort-mode:hover {
378
+ background: var(--dsw-alias-interactive-bg-hover);
379
+ }
380
+
381
+ .mr-sort-mode input[type="radio"] {
382
+ margin-top: 2px;
383
+ accent-color: var(--dsw-alias-accent);
384
+ }
385
+
386
+ .mr-sort-mode-copy {
387
+ display: flex;
388
+ flex-direction: column;
389
+ gap: 1px;
390
+ min-width: 0;
391
+ }
392
+
393
+ .mr-sort-mode-label {
394
+ color: var(--dsw-alias-label-primary);
395
+ font-size: 13px;
396
+ line-height: 20px;
397
+ }
398
+
399
+ .mr-sort-mode-desc {
400
+ color: var(--dsw-alias-label-tertiary);
401
+ font-size: 12px;
402
+ line-height: 18px;
403
+ }
404
+
405
+ .mr-sort-custom {
406
+ display: flex;
407
+ flex-direction: column;
408
+ gap: 6px;
409
+ border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);
410
+ padding-top: 8px;
411
+ }
412
+
413
+ .mr-sort-hint {
414
+ color: var(--dsw-alias-label-caption);
415
+ margin: 0;
416
+ font-size: 11px;
417
+ line-height: 16px;
418
+ }
419
+
420
+ .mr-sort-list {
421
+ list-style: none;
422
+ margin: 0;
423
+ padding: 0;
424
+ display: flex;
425
+ flex-direction: column;
426
+ gap: 4px;
427
+ max-height: 280px;
428
+ overflow-y: auto;
429
+ }
430
+
431
+ .mr-sort-item {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: 6px;
435
+ border: 1px solid var(--dsw-alias-border-l2, #2a3346);
436
+ border-radius: 8px;
437
+ padding: 4px 8px 4px 4px;
438
+ background: var(--dsw-alias-bg-layer-1, #0d1017);
439
+ }
440
+
441
+ .mr-sort-item-drop {
442
+ border-color: var(--dsw-alias-border-brand);
443
+ }
444
+
445
+ .mr-sort-item-busy {
446
+ opacity: 0.6;
447
+ pointer-events: none;
448
+ }
449
+
450
+ .mr-sort-model-name {
451
+ color: var(--dsw-alias-label-primary);
452
+ font-size: 13px;
453
+ line-height: 20px;
454
+ text-overflow: ellipsis;
455
+ white-space: nowrap;
456
+ overflow: hidden;
457
+ min-width: 0;
458
+ }
459
+
460
+ .mr-sort-model-id {
461
+ color: var(--dsw-alias-label-tertiary);
462
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
463
+ font-size: 11px;
464
+ line-height: 16px;
465
+ text-overflow: ellipsis;
466
+ white-space: nowrap;
467
+ overflow: hidden;
468
+ flex: 1;
469
+ text-align: right;
470
+ }
@@ -16,13 +16,14 @@
16
16
  * mutation controls.
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
19
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+ type ClientContext = Context
20
21
  import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
21
22
  import { LOCALE_NS } from './locales'
22
- import { api, type ModelRouterState, type RouterModelView } from './api'
23
+ import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
23
24
  import './settings-card.css'
24
25
 
25
- /** Client settings scope face (subset of @deepseek-ai/dsh-client-runtime). */
26
+ /** Client settings scope face (subset of the app client modules). */
26
27
  export interface SettingsScopeFace {
27
28
  getSnapshot(): {
28
29
  status: 'loading' | 'ready' | 'unavailable'
@@ -93,6 +94,8 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
93
94
  const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
94
95
  const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
95
96
  const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
97
+ const [sortBusy, setSortBusy] = useState(false)
98
+ const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
96
99
 
97
100
  const load = useCallback(async () => {
98
101
  setLoading(true)
@@ -177,6 +180,41 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
177
180
  }
178
181
  }, [state])
179
182
 
183
+ const changeModelSort = useCallback(async (mode: ModelSortMode) => {
184
+ if (state === null || state.modelSort === mode) return
185
+ const previous = state.modelSort
186
+ setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
187
+ setSortBusy(true)
188
+ try {
189
+ setState(await api.setModelSort(mode))
190
+ setError(null)
191
+ } catch (cause) {
192
+ setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
193
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
194
+ } finally {
195
+ setSortBusy(false)
196
+ }
197
+ }, [state, t])
198
+
199
+ const moveModel = useCallback(async (from: number, to: number) => {
200
+ if (state === null || from === to) return
201
+ // The drag list shows the full display order (modelOrder + catalog
202
+ // tail); persist the whole reordered id list as the new custom order.
203
+ const ids = state.models.map(model => model.id)
204
+ const order = arrayMove(ids, from, to)
205
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
206
+ setSortBusy(true)
207
+ try {
208
+ setState(await api.setModelOrder(order))
209
+ setError(null)
210
+ } catch (cause) {
211
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
212
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
213
+ } finally {
214
+ setSortBusy(false)
215
+ }
216
+ }, [state, t])
217
+
180
218
  // The namespace is served by the host once the scope is ready. While it is
181
219
  // merely loading, keep the card mounted (the tab already dispatched it);
182
220
  // if it is unavailable (deployment without the host half), render nothing.
@@ -235,6 +273,77 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
235
273
  <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
236
274
  </span>
237
275
  </label>
276
+ <div className="mr-sort">
277
+ <div className="mr-sort-head">
278
+ <span className="mr-sort-title">{t('sortTitle')}</span>
279
+ <span className="mr-sort-desc">{t('sortDescription')}</span>
280
+ </div>
281
+ <div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
282
+ {(['custom', 'name', 'recent'] as const).map(mode => (
283
+ <label key={mode} className="mr-sort-mode">
284
+ <input
285
+ type="radio"
286
+ name="mr-model-sort"
287
+ checked={state?.modelSort === mode}
288
+ disabled={loading || sortBusy || !writable}
289
+ onChange={() => void changeModelSort(mode)}
290
+ />
291
+ <span className="mr-sort-mode-copy">
292
+ <span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
293
+ <span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
294
+ </span>
295
+ </label>
296
+ ))}
297
+ </div>
298
+ {state?.modelSort === 'custom' && (
299
+ <div className="mr-sort-custom">
300
+ <p className="mr-sort-hint">{t('sortCustomHint')}</p>
301
+ <ol className="mr-sort-list">
302
+ {models.map((model, index) => {
303
+ const dropTarget = modelDrag !== null && modelDrag.index !== index
304
+ return (
305
+ <li
306
+ key={model.id}
307
+ className={[
308
+ 'mr-sort-item',
309
+ dropTarget ? 'mr-sort-item-drop' : '',
310
+ sortBusy ? 'mr-sort-item-busy' : '',
311
+ ].filter(Boolean).join(' ')}
312
+ draggable={!sortBusy && writable}
313
+ onDragStart={(event) => {
314
+ setModelDrag({ index })
315
+ event.dataTransfer.effectAllowed = 'move'
316
+ try {
317
+ event.dataTransfer.setData('text/plain', model.id)
318
+ } catch {
319
+ // drag data is cosmetic for our own handler
320
+ }
321
+ }}
322
+ onDragOver={(event) => {
323
+ if (modelDrag !== null && modelDrag.index !== index) {
324
+ event.preventDefault()
325
+ event.dataTransfer.dropEffect = 'move'
326
+ }
327
+ }}
328
+ onDrop={(event) => {
329
+ if (modelDrag !== null && modelDrag.index !== index) {
330
+ event.preventDefault()
331
+ moveModel(modelDrag.index, index)
332
+ }
333
+ setModelDrag(null)
334
+ }}
335
+ onDragEnd={() => setModelDrag(null)}
336
+ >
337
+ <span className="mr-grip" aria-hidden="true">⠿</span>
338
+ <span className="mr-sort-model-name">{model.name}</span>
339
+ <span className="mr-sort-model-id">{model.id}</span>
340
+ </li>
341
+ )
342
+ })}
343
+ </ol>
344
+ </div>
345
+ )}
346
+ </div>
238
347
  <div className="mr-toolbar">
239
348
  <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
240
349
  <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
@@ -13,12 +13,14 @@ import type {
13
13
  LlmModelInfo,
14
14
  LlmProviderInfo,
15
15
  LlmResolvedModelInfo,
16
+ PreparedAdapterCall,
16
17
  StreamChunk,
17
18
  } from '@deepseek-ai/dsh-llm'
18
19
  import {
19
20
  displayProvider,
20
21
  resolveActive,
21
22
  ROUTER_PROVIDER_ID,
23
+ sortModelsForList,
22
24
  stripModelIdPrefix,
23
25
  type MergedModel,
24
26
  type RouterConfigShape,
@@ -34,6 +36,8 @@ export interface RouterFacts {
34
36
  providerNames(): ReadonlyMap<string, string>
35
37
  /** The LLM runtime to delegate to. */
36
38
  llm(): LlmRuntime
39
+ /** Record one model request for the recent-use ordering (fire-and-forget). */
40
+ noteModelUsed(modelId: string): void
37
41
  }
38
42
 
39
43
  /** One resolved route for a model id. */
@@ -76,6 +80,27 @@ export function entryRawId(merged: MergedModel, providerId: string): string {
76
80
  return merged.providers.find(provider => provider.provider === providerId)?.id ?? merged.id
77
81
  }
78
82
 
83
+ /** One validated route plus the merged model it was resolved against. */
84
+ export interface BoundRoute {
85
+ route: ResolvedRoute
86
+ merged: MergedModel
87
+ }
88
+
89
+ function unroutable(model: string): LlmError {
90
+ return new LlmError(`model-router: no provider serves model "${model}"`, 'NO_ADAPTER')
91
+ }
92
+
93
+ /**
94
+ * Resolve one model id to its active provider and merged model, rejecting
95
+ * unadvertised or currently unroutable ids before any provider I/O.
96
+ */
97
+ export function bindRoute(facts: RouterFacts, modelId: string): BoundRoute {
98
+ const merged = mergedFor(facts, modelId)
99
+ const route = merged === null ? null : routeFor(facts, modelId)
100
+ if (merged === null || route === null) throw unroutable(modelId)
101
+ return { route, merged }
102
+ }
103
+
79
104
  export class ModelRouterAdapter extends LlmAdapter {
80
105
  constructor(private readonly facts: RouterFacts) {
81
106
  super()
@@ -86,10 +111,11 @@ export class ModelRouterAdapter extends LlmAdapter {
86
111
  }
87
112
 
88
113
  async listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
114
+ const config = this.facts.config()
89
115
  const { models } = this.facts.catalog()
90
116
  const names = this.facts.providerNames()
91
117
  const out: LlmModelInfo[] = []
92
- for (const merged of models) {
118
+ for (const merged of sortModelsForList(models, config)) {
93
119
  const route = routeFor(this.facts, merged.id)
94
120
  const display = displayProvider(merged, route?.provider)
95
121
  if (display === null) continue
@@ -104,14 +130,35 @@ export class ModelRouterAdapter extends LlmAdapter {
104
130
  }
105
131
 
106
132
  async resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
107
- const route = routeFor(this.facts, model)
108
- const merged = route === null ? null : mergedFor(this.facts, model)
109
- if (route === null || merged === null) {
110
- throw new LlmError(`model-router: no provider serves model "${model}"`, 'NO_ADAPTER')
133
+ const { route, merged } = bindRoute(this.facts, model)
134
+ return this.resolveRoutedIdentity(route, merged, signal)
135
+ }
136
+
137
+ /**
138
+ * Bind the route generation at preparation time: a provider switch or
139
+ * catalog refresh between preparation and dispatch cannot combine one
140
+ * generation's metadata with another's endpoint, matching the host's
141
+ * one-generation dispatch contract.
142
+ */
143
+ async prepareCall(_provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall> {
144
+ const { route, merged } = bindRoute(this.facts, model)
145
+ return {
146
+ model: await this.resolveRoutedIdentity(route, merged, signal),
147
+ stream: (options: GenerateOptions) => this.streamRouted(options, route, merged),
111
148
  }
112
- // Delegate with the provider's raw local id (it may carry a vendor/
113
- // prefix the real provider needs), then normalize the resolved identity
114
- // back to the merged display id so sessions/metering never leak prefixes.
149
+ }
150
+
151
+ async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
152
+ const { route, merged } = bindRoute(this.facts, options.model)
153
+ yield* this.streamRouted(options, route, merged)
154
+ }
155
+
156
+ /**
157
+ * Delegate with the provider's raw local id (it may carry a vendor/
158
+ * prefix the real provider needs), then normalize the resolved identity
159
+ * back to the merged display id so sessions/metering never leak prefixes.
160
+ */
161
+ private async resolveRoutedIdentity(route: ResolvedRoute, merged: MergedModel, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
115
162
  const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal)
116
163
  const display = displayProvider(merged, route.provider)
117
164
  return {
@@ -122,11 +169,13 @@ export class ModelRouterAdapter extends LlmAdapter {
122
169
  }
123
170
  }
124
171
 
125
- async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
126
- const route = routeFor(this.facts, options.model)
127
- const merged = route === null ? null : mergedFor(this.facts, options.model)
128
- if (route === null || merged === null) {
129
- throw new LlmError(`model-router: no provider serves model "${options.model}"`, 'NO_ADAPTER')
172
+ private async *streamRouted(options: GenerateOptions, route: ResolvedRoute, merged: MergedModel): AsyncIterable<StreamChunk> {
173
+ // Record the use for the recent-use model ordering; never blocks or
174
+ // fails the request (the service debounces persistence).
175
+ try {
176
+ this.facts.noteModelUsed(merged.id)
177
+ } catch {
178
+ // advisory only
130
179
  }
131
180
  // In-process delegation: the inner call resolves the real adapter and
132
181
  // runs its full pipeline (retry, waterfall, replay-state stripping). The
package/src/server/rpc.ts CHANGED
@@ -150,6 +150,22 @@ export function registerApiRoutes(ctx: Context, service: ModelRouterService): ()
150
150
  return service.state()
151
151
  })
152
152
 
153
+ route('set-model-sort', async (p) => {
154
+ if (p.mode !== 'custom' && p.mode !== 'name' && p.mode !== 'recent') {
155
+ throw new Error('mode 必须是 custom、name 或 recent')
156
+ }
157
+ await service.setModelSort(p.mode)
158
+ return service.state()
159
+ })
160
+
161
+ route('set-model-order', async (p) => {
162
+ if (!Array.isArray(p.order) || !p.order.every((id): id is string => typeof id === 'string')) {
163
+ throw new Error('order 必须是模型 id 数组')
164
+ }
165
+ await service.setModelOrder(p.order)
166
+ return service.state()
167
+ })
168
+
153
169
  return () => {
154
170
  for (const dispose of disposers) dispose()
155
171
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { Context } from '@deepseek-ai/cordis'
7
7
  import z from '@deepseek-ai/schemastery'
8
- import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
8
+ import type { SettingsScope } from '@deepseek-ai/dsh-settings'
9
9
  import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
10
10
  import {
11
11
  initialConfigFor,
@@ -16,12 +16,14 @@ 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'
22
24
  import { ModelRouterAdapter, type RouterFacts } from './adapter.js'
23
25
 
24
- export const NS = settingsNamespace(ROUTER_SETTINGS_NS)
26
+ export const NS = ROUTER_SETTINGS_NS
25
27
 
26
28
  /** Settings document schema: one entry per model id. */
27
29
  export const RouterConfigSchema: z<RouterConfigShape> = z.object({
@@ -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 = { models: {}, showQuickSwitch: true, ignoreModelIdPrefix: true }
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 quick route-switcher button is enabled. */
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
@@ -73,6 +89,12 @@ export class ModelRouterService implements RouterFacts {
73
89
  /** How long a built catalog is considered fresh before being rebuilt. */
74
90
  private readonly catalogTtlMs = 30_000
75
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
+
76
98
  constructor(ctx: Context) {
77
99
  this.ctx = ctx
78
100
  }
@@ -99,6 +121,14 @@ export class ModelRouterService implements RouterFacts {
99
121
  }
100
122
 
101
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
+ }
102
132
  this.registration?.()
103
133
  this.registration = undefined
104
134
  }
@@ -126,6 +156,45 @@ export class ModelRouterService implements RouterFacts {
126
156
  return this.ctx.llm
127
157
  }
128
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
+
129
198
  // ── catalog lifecycle ────────────────────────────────────────────────────
130
199
 
131
200
  /**
@@ -166,13 +235,13 @@ export class ModelRouterService implements RouterFacts {
166
235
  const catalog = await this.refreshCatalog(true)
167
236
  if (this.scope === undefined) return
168
237
  const current = this.config()
169
- const { models, changed } = reconcileConfig(current, catalog.models)
238
+ const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
170
239
  if (!changed) return
171
240
  try {
172
241
  await this.scope.replace({
242
+ ...current,
173
243
  models,
174
- showQuickSwitch: current.showQuickSwitch,
175
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
244
+ ...(modelOrder === undefined ? {} : { modelOrder }),
176
245
  })
177
246
  } catch (error) {
178
247
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
@@ -197,12 +266,14 @@ export class ModelRouterService implements RouterFacts {
197
266
  return {
198
267
  showQuickSwitch: config.showQuickSwitch,
199
268
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
269
+ modelSort: config.modelSort,
270
+ modelOrder: config.modelOrder,
200
271
  providers: catalog.providers.map(provider => ({
201
272
  id: provider.id,
202
273
  name: names.get(provider.id) ?? provider.id,
203
274
  credentialConfigured: creds.get(provider.id) ?? true,
204
275
  })),
205
- models: catalog.models.map((model) => {
276
+ models: sortModelsForList(catalog.models, config).map((model) => {
206
277
  const ids = model.providers.map(provider => provider.provider)
207
278
  const stored = config.models[model.id] ?? initialConfigFor(ids)
208
279
  const active = resolveActive(config, model.id, ids)
@@ -253,11 +324,7 @@ export class ModelRouterService implements RouterFacts {
253
324
  }
254
325
  const current = this.config()
255
326
  if (current.showQuickSwitch === value) return
256
- await this.scope.replace({
257
- models: current.models,
258
- showQuickSwitch: value,
259
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
260
- })
327
+ await this.scope.replace({ ...current, showQuickSwitch: value })
261
328
  }
262
329
 
263
330
  /**
@@ -272,23 +339,47 @@ export class ModelRouterService implements RouterFacts {
272
339
  }
273
340
  const current = this.config()
274
341
  if (current.ignoreModelIdPrefix === value) return
275
- await this.scope.replace({
276
- models: current.models,
277
- showQuickSwitch: current.showQuickSwitch,
278
- ignoreModelIdPrefix: value,
279
- })
342
+ await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
280
343
  await this.refreshAndReconcile()
281
344
  }
282
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
+
283
378
  private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
284
379
  if (this.scope === undefined) {
285
380
  throw new Error('model-router: settings service is not available in this profile')
286
381
  }
287
382
  const current = this.config()
288
- await this.scope.replace({
289
- models,
290
- showQuickSwitch: current.showQuickSwitch,
291
- ignoreModelIdPrefix: current.ignoreModelIdPrefix,
292
- })
383
+ await this.scope.replace({ ...current, models })
293
384
  }
294
385
  }