@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.
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The Model Router Settings Card (设置 → 插件 → 插件配置 → 模型路由).
3
+ *
4
+ * Renders its own expandable chrome aligned with the built-in plugin cards
5
+ * (external plugins cannot import PluginCard): a header naming the plugin and
6
+ * what its settings govern, disclosing the router controls in place. All
7
+ * reads/mutations go through the plugin's fenced API; the server persists
8
+ * through the `model-router` settings namespace, so a switch here is live for
9
+ * the next request globally.
10
+ *
11
+ * The bound settings scope (the same `model-router` namespace the host half
12
+ * registers) supplies the card's dispatch state: while the namespace is merely
13
+ * loading the card stays mounted, and when it is unavailable (deployment
14
+ * without the host half) nothing renders — matching the built-in cards. A
15
+ * read-only deployment shows the built-in card's banner and disables the
16
+ * mutation controls.
17
+ */
18
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
19
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
20
+ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
21
+ import { LOCALE_NS } from './locales'
22
+ import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
23
+ import './settings-card.css'
24
+
25
+ /** Client settings scope face (subset of @deepseek-ai/dsh-client-runtime). */
26
+ export interface SettingsScopeFace {
27
+ getSnapshot(): {
28
+ status: 'loading' | 'ready' | 'unavailable'
29
+ writable: boolean
30
+ }
31
+ subscribe(listener: () => void): () => void
32
+ }
33
+
34
+ export interface SettingsCardProps {
35
+ /** The bound `model-router` settings scope (from the slot entry's inject face). */
36
+ scope: SettingsScopeFace
37
+ }
38
+
39
+ function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
40
+ const next = [...list]
41
+ const [item] = next.splice(from, 1)
42
+ next.splice(to, 0, item)
43
+ return next
44
+ }
45
+
46
+ function useLocaleRevision(ctx: ClientContext): number {
47
+ const subscribe = useCallback(
48
+ (onChange: () => void) => {
49
+ try {
50
+ return ctx.locale.subscribe(onChange)
51
+ } catch {
52
+ return () => {}
53
+ }
54
+ },
55
+ [ctx],
56
+ )
57
+ const getSnapshot = useCallback(() => {
58
+ try {
59
+ return ctx.locale.getLocale().revision
60
+ } catch {
61
+ return 0
62
+ }
63
+ }, [ctx])
64
+ return useSyncExternalStore(subscribe, getSnapshot)
65
+ }
66
+
67
+ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps) => JSX.Element | null {
68
+ // Bound translation is namespace-typed; the card's props use the plain
69
+ // Translate face (string keys), which the dict satisfies structurally.
70
+ const t: Translate = (() => {
71
+ try {
72
+ return ctx.locale.bind(LOCALE_NS) as unknown as Translate
73
+ } catch {
74
+ return (key: string) => key
75
+ }
76
+ })()
77
+
78
+ return function ModelRouterSettingsCard(props: SettingsCardProps): JSX.Element | null {
79
+ const { scope } = props
80
+ // Bind the methods: React invokes getSnapshot/subscribe as bare functions,
81
+ // and SettingsScopeController's methods depend on `this`.
82
+ const snapshot = useSyncExternalStore(
83
+ (listener) => scope.subscribe(listener),
84
+ () => scope.getSnapshot(),
85
+ )
86
+ useLocaleRevision(ctx)
87
+ // Card-local disclosure: collapsed by default, like the built-in plugin cards.
88
+ const [open, setOpen] = useState(false)
89
+ const [state, setState] = useState<ModelRouterState | null>(null)
90
+ const [error, setError] = useState<string | null>(null)
91
+ const [loading, setLoading] = useState(true)
92
+ const [busyModel, setBusyModel] = useState<string | null>(null)
93
+ const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
94
+ const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
95
+ const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
96
+ const [sortBusy, setSortBusy] = useState(false)
97
+ const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
98
+
99
+ const load = useCallback(async () => {
100
+ setLoading(true)
101
+ try {
102
+ setState(await api.state())
103
+ setError(null)
104
+ } catch (cause) {
105
+ setError(cause instanceof Error ? cause.message : String(cause))
106
+ } finally {
107
+ setLoading(false)
108
+ }
109
+ }, [])
110
+
111
+ useEffect(() => {
112
+ void load()
113
+ }, [load])
114
+
115
+ const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
116
+ if (model.active === providerId) return
117
+ setBusyModel(model.id)
118
+ try {
119
+ setState(await api.switchActive(model.id, providerId))
120
+ setError(null)
121
+ } catch (cause) {
122
+ setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
123
+ } finally {
124
+ setBusyModel(null)
125
+ }
126
+ }, [t])
127
+
128
+ const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
129
+ if (from === to) return
130
+ const order = arrayMove(model.order, from, to)
131
+ setState(prev => prev === null ? prev : {
132
+ ...prev,
133
+ models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
134
+ })
135
+ setBusyModel(model.id)
136
+ try {
137
+ setState(await api.reorder(model.id, order))
138
+ setError(null)
139
+ } catch (cause) {
140
+ setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
141
+ setState(prev => prev === null ? prev : {
142
+ ...prev,
143
+ models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
144
+ })
145
+ } finally {
146
+ setBusyModel(null)
147
+ }
148
+ }, [t])
149
+
150
+ const toggleQuickSwitch = useCallback(async (value: boolean) => {
151
+ if (state === null) return
152
+ const previous = state.showQuickSwitch
153
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
154
+ setQuickSwitchBusy(true)
155
+ try {
156
+ setState(await api.setShowQuickSwitch(value))
157
+ setError(null)
158
+ } catch (cause) {
159
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
160
+ setError(cause instanceof Error ? cause.message : String(cause))
161
+ } finally {
162
+ setQuickSwitchBusy(false)
163
+ }
164
+ }, [state])
165
+
166
+ const toggleIgnorePrefix = useCallback(async (value: boolean) => {
167
+ if (state === null) return
168
+ const previous = state.ignoreModelIdPrefix
169
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
170
+ setIgnorePrefixBusy(true)
171
+ try {
172
+ setState(await api.setIgnoreModelIdPrefix(value))
173
+ setError(null)
174
+ } catch (cause) {
175
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
176
+ setError(cause instanceof Error ? cause.message : String(cause))
177
+ } finally {
178
+ setIgnorePrefixBusy(false)
179
+ }
180
+ }, [state])
181
+
182
+ const changeModelSort = useCallback(async (mode: ModelSortMode) => {
183
+ if (state === null || state.modelSort === mode) return
184
+ const previous = state.modelSort
185
+ setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
186
+ setSortBusy(true)
187
+ try {
188
+ setState(await api.setModelSort(mode))
189
+ setError(null)
190
+ } catch (cause) {
191
+ setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
192
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
193
+ } finally {
194
+ setSortBusy(false)
195
+ }
196
+ }, [state, t])
197
+
198
+ const moveModel = useCallback(async (from: number, to: number) => {
199
+ if (state === null || from === to) return
200
+ // The drag list shows the full display order (modelOrder + catalog
201
+ // tail); persist the whole reordered id list as the new custom order.
202
+ const ids = state.models.map(model => model.id)
203
+ const order = arrayMove(ids, from, to)
204
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
205
+ setSortBusy(true)
206
+ try {
207
+ setState(await api.setModelOrder(order))
208
+ setError(null)
209
+ } catch (cause) {
210
+ setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
211
+ setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
212
+ } finally {
213
+ setSortBusy(false)
214
+ }
215
+ }, [state, t])
216
+
217
+ // The namespace is served by the host once the scope is ready. While it is
218
+ // merely loading, keep the card mounted (the tab already dispatched it);
219
+ // if it is unavailable (deployment without the host half), render nothing.
220
+ if (snapshot.status === 'unavailable') return null
221
+ const writable = snapshot.writable
222
+
223
+ const models = useMemo(() => state?.models ?? [], [state])
224
+
225
+ return (
226
+ <li className="mr-settings-card" data-open={open ? '' : undefined}>
227
+ <button
228
+ type="button"
229
+ className="mr-settings-head"
230
+ aria-expanded={open}
231
+ aria-label={(open ? t('collapse') : t('expand')) + ':' + t('settingsTitle')}
232
+ onClick={() => setOpen(!open)}
233
+ >
234
+ <span className="mr-settings-head-text">
235
+ <span className="mr-settings-title">{t('settingsTitle')}</span>
236
+ <span className="mr-settings-sub">{t('settingsCardDescription')}</span>
237
+ </span>
238
+ <span className="mr-settings-chevron" data-open={open ? '' : undefined} aria-hidden="true">
239
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
240
+ <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
241
+ strokeLinecap="round" strokeLinejoin="round" />
242
+ </svg>
243
+ </span>
244
+ </button>
245
+ {open && (
246
+ <div className="mr-settings-body">
247
+ {!writable && (
248
+ <p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
249
+ )}
250
+ <div className="mr-root">
251
+ <label className="mr-quick-switch">
252
+ <input
253
+ type="checkbox"
254
+ checked={state?.showQuickSwitch ?? true}
255
+ disabled={loading || quickSwitchBusy || !writable}
256
+ onChange={(event) => void toggleQuickSwitch(event.target.checked)}
257
+ />
258
+ <span className="mr-quick-switch-copy">
259
+ <span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
260
+ <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
261
+ </span>
262
+ </label>
263
+ <label className="mr-quick-switch">
264
+ <input
265
+ type="checkbox"
266
+ checked={state?.ignoreModelIdPrefix ?? true}
267
+ disabled={loading || ignorePrefixBusy || !writable}
268
+ onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
269
+ />
270
+ <span className="mr-quick-switch-copy">
271
+ <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
272
+ <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
273
+ </span>
274
+ </label>
275
+ <div className="mr-sort">
276
+ <div className="mr-sort-head">
277
+ <span className="mr-sort-title">{t('sortTitle')}</span>
278
+ <span className="mr-sort-desc">{t('sortDescription')}</span>
279
+ </div>
280
+ <div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
281
+ {(['custom', 'name', 'recent'] as const).map(mode => (
282
+ <label key={mode} className="mr-sort-mode">
283
+ <input
284
+ type="radio"
285
+ name="mr-model-sort"
286
+ checked={state?.modelSort === mode}
287
+ disabled={loading || sortBusy || !writable}
288
+ onChange={() => void changeModelSort(mode)}
289
+ />
290
+ <span className="mr-sort-mode-copy">
291
+ <span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
292
+ <span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
293
+ </span>
294
+ </label>
295
+ ))}
296
+ </div>
297
+ {state?.modelSort === 'custom' && (
298
+ <div className="mr-sort-custom">
299
+ <p className="mr-sort-hint">{t('sortCustomHint')}</p>
300
+ <ol className="mr-sort-list">
301
+ {models.map((model, index) => {
302
+ const dropTarget = modelDrag !== null && modelDrag.index !== index
303
+ return (
304
+ <li
305
+ key={model.id}
306
+ className={[
307
+ 'mr-sort-item',
308
+ dropTarget ? 'mr-sort-item-drop' : '',
309
+ sortBusy ? 'mr-sort-item-busy' : '',
310
+ ].filter(Boolean).join(' ')}
311
+ draggable={!sortBusy && writable}
312
+ onDragStart={(event) => {
313
+ setModelDrag({ index })
314
+ event.dataTransfer.effectAllowed = 'move'
315
+ try {
316
+ event.dataTransfer.setData('text/plain', model.id)
317
+ } catch {
318
+ // drag data is cosmetic for our own handler
319
+ }
320
+ }}
321
+ onDragOver={(event) => {
322
+ if (modelDrag !== null && modelDrag.index !== index) {
323
+ event.preventDefault()
324
+ event.dataTransfer.dropEffect = 'move'
325
+ }
326
+ }}
327
+ onDrop={(event) => {
328
+ if (modelDrag !== null && modelDrag.index !== index) {
329
+ event.preventDefault()
330
+ moveModel(modelDrag.index, index)
331
+ }
332
+ setModelDrag(null)
333
+ }}
334
+ onDragEnd={() => setModelDrag(null)}
335
+ >
336
+ <span className="mr-grip" aria-hidden="true">⠿</span>
337
+ <span className="mr-sort-model-name">{model.name}</span>
338
+ <span className="mr-sort-model-id">{model.id}</span>
339
+ </li>
340
+ )
341
+ })}
342
+ </ol>
343
+ </div>
344
+ )}
345
+ </div>
346
+ <div className="mr-toolbar">
347
+ <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
348
+ <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
349
+ {t('refresh')}
350
+ </button>
351
+ </div>
352
+ {error !== null && (
353
+ <div className="mr-error" role="alert">{error}</div>
354
+ )}
355
+ {loading && models.length === 0 && (
356
+ <div className="mr-empty">{t('loading')}</div>
357
+ )}
358
+ {!loading && models.length === 0 && (
359
+ <div className="mr-empty">{t('empty')}</div>
360
+ )}
361
+ <div className="mr-models">
362
+ {models.map(model => (
363
+ <ModelCard
364
+ key={model.id}
365
+ model={model}
366
+ t={t}
367
+ readOnly={!writable}
368
+ busy={busyModel === model.id}
369
+ dragging={drag}
370
+ onDragStart={index => setDrag({ modelId: model.id, index })}
371
+ onDragEnd={() => setDrag(null)}
372
+ onDrop={index => {
373
+ if (drag !== null && drag.modelId === model.id) {
374
+ void reorder(model, drag.index, index)
375
+ }
376
+ setDrag(null)
377
+ }}
378
+ onSwitch={providerId => void switchActive(model, providerId)}
379
+ />
380
+ ))}
381
+ </div>
382
+ </div>
383
+ </div>
384
+ )}
385
+ </li>
386
+ )
387
+ }
388
+ }
389
+
390
+ interface ModelCardProps {
391
+ model: RouterModelView
392
+ t: Translate
393
+ readOnly: boolean
394
+ busy: boolean
395
+ dragging: { modelId: string; index: number } | null
396
+ onDragStart: (index: number) => void
397
+ onDragEnd: () => void
398
+ onDrop: (index: number) => void
399
+ onSwitch: (providerId: string) => void
400
+ }
401
+
402
+ function ModelCard(props: ModelCardProps): JSX.Element {
403
+ const { model, t, readOnly, busy, dragging } = props
404
+ return (
405
+ <section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
406
+ <header className="mr-card-header">
407
+ <span className="mr-model-name">{model.name}</span>
408
+ <span className="mr-model-id">{model.id}</span>
409
+ <span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
410
+ </header>
411
+ <p className="mr-drag-hint">{t('dragHint')}</p>
412
+ <ul className="mr-providers">
413
+ {model.providers.map((provider, index) => {
414
+ const active = provider.id === model.active
415
+ const selectable = provider.credentialConfigured
416
+ const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
417
+ return (
418
+ <li
419
+ key={provider.id}
420
+ className={[
421
+ 'mr-provider',
422
+ active ? 'mr-provider-active' : '',
423
+ !selectable ? 'mr-provider-nocred' : '',
424
+ dropTarget ? 'mr-provider-drop' : '',
425
+ ].filter(Boolean).join(' ')}
426
+ draggable={!busy && !readOnly && model.providers.length > 1}
427
+ title={selectable ? undefined : t('noCredential')}
428
+ onDragStart={(event) => {
429
+ props.onDragStart(index)
430
+ event.dataTransfer.effectAllowed = 'move'
431
+ try {
432
+ event.dataTransfer.setData('text/plain', model.id)
433
+ } catch {
434
+ // drag data is cosmetic for our own handler
435
+ }
436
+ }}
437
+ onDragOver={(event) => {
438
+ if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
439
+ event.preventDefault()
440
+ event.dataTransfer.dropEffect = 'move'
441
+ }
442
+ }}
443
+ onDrop={(event) => {
444
+ if (dragging !== null && dragging.modelId === model.id) {
445
+ event.preventDefault()
446
+ props.onDrop(index)
447
+ }
448
+ }}
449
+ onDragEnd={() => props.onDragEnd()}
450
+ >
451
+ <span className="mr-grip" aria-hidden="true">⠿</span>
452
+ <button
453
+ type="button"
454
+ className="mr-provider-button"
455
+ disabled={!selectable || busy || active || readOnly}
456
+ onClick={() => props.onSwitch(provider.id)}
457
+ >
458
+ <span className="mr-provider-name">{provider.name}</span>
459
+ {!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
460
+ </button>
461
+ {active && <span className="mr-active-badge">{t('activeBadge')}</span>}
462
+ </li>
463
+ )
464
+ })}
465
+ </ul>
466
+ </section>
467
+ )
468
+ }
@@ -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
  }