@fonlan/dsh-model-router 0.1.0 → 0.2.1

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.
@@ -3,7 +3,9 @@
3
3
  * route `model-router`, it advertises the merged catalog and, at request time,
4
4
  * rewrites the provider field to the model's currently active provider and
5
5
  * delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
6
- * and metering all stay inside the real adapter's pipeline.
6
+ * and metering all stay inside the real adapter's pipeline. Incoming model
7
+ * ids are matched with the leading vendor prefix ignored when the settings
8
+ * flag is on, and delegation always uses the active provider's raw local id.
7
9
  */
8
10
  import { LlmAdapter, LlmError, type LlmRuntime } from '@deepseek-ai/dsh-llm'
9
11
  import type {
@@ -17,6 +19,7 @@ import {
17
19
  displayProvider,
18
20
  resolveActive,
19
21
  ROUTER_PROVIDER_ID,
22
+ stripModelIdPrefix,
20
23
  type MergedModel,
21
24
  type RouterConfigShape,
22
25
  } from '../shared/config.js'
@@ -38,19 +41,41 @@ export interface ResolvedRoute {
38
41
  provider: string
39
42
  }
40
43
 
44
+ /**
45
+ * Find the merged model for an incoming model id. Exact lookup first; when
46
+ * prefix-ignore is on, a miss falls back to the prefix-stripped id so a raw
47
+ * `deepseek/deepseek-v4-flash` request matches the merged `deepseek-v4-flash`
48
+ * (advertised ids are already stripped and hit exactly). Unadvertised ids do
49
+ * not match.
50
+ */
51
+ export function mergedFor(facts: RouterFacts, modelId: string): MergedModel | null {
52
+ const byId = facts.catalog().byId
53
+ const direct = byId.get(modelId)
54
+ if (direct !== undefined) return direct
55
+ if (!facts.config().ignoreModelIdPrefix) return null
56
+ const stripped = stripModelIdPrefix(modelId)
57
+ if (stripped === modelId) return null
58
+ return byId.get(stripped) ?? null
59
+ }
60
+
41
61
  /**
42
62
  * Resolve the effective route for one model id: the stored active provider
43
63
  * when still present, otherwise the first remaining order entry, otherwise
44
64
  * the catalog head. Unadvertised model ids do not route.
45
65
  */
46
66
  export function routeFor(facts: RouterFacts, modelId: string): ResolvedRoute | null {
47
- const merged = facts.catalog().byId.get(modelId)
48
- if (merged === undefined) return null
67
+ const merged = mergedFor(facts, modelId)
68
+ if (merged === null) return null
49
69
  const ids = merged.providers.map(provider => provider.provider)
50
- const active = resolveActive(facts.config(), modelId, ids)
70
+ const active = resolveActive(facts.config(), merged.id, ids)
51
71
  return active === null ? null : { provider: active }
52
72
  }
53
73
 
74
+ /** The raw provider-local id of one provider entry inside a merged model. */
75
+ export function entryRawId(merged: MergedModel, providerId: string): string {
76
+ return merged.providers.find(provider => provider.provider === providerId)?.id ?? merged.id
77
+ }
78
+
54
79
  export class ModelRouterAdapter extends LlmAdapter {
55
80
  constructor(private readonly facts: RouterFacts) {
56
81
  super()
@@ -80,20 +105,37 @@ export class ModelRouterAdapter extends LlmAdapter {
80
105
 
81
106
  async resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
82
107
  const route = routeFor(this.facts, model)
83
- if (route === null) {
108
+ const merged = route === null ? null : mergedFor(this.facts, model)
109
+ if (route === null || merged === null) {
84
110
  throw new LlmError(`model-router: no provider serves model "${model}"`, 'NO_ADAPTER')
85
111
  }
86
- const resolved = await this.facts.llm().resolveModelInfo(route.provider, model, signal)
87
- return { ...resolved, provider: ROUTER_PROVIDER_ID }
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.
115
+ const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal)
116
+ const display = displayProvider(merged, route.provider)
117
+ return {
118
+ ...resolved,
119
+ provider: ROUTER_PROVIDER_ID,
120
+ id: merged.id,
121
+ name: display?.name ?? resolved.name,
122
+ }
88
123
  }
89
124
 
90
125
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
91
126
  const route = routeFor(this.facts, options.model)
92
- if (route === null) {
127
+ const merged = route === null ? null : mergedFor(this.facts, options.model)
128
+ if (route === null || merged === null) {
93
129
  throw new LlmError(`model-router: no provider serves model "${options.model}"`, 'NO_ADAPTER')
94
130
  }
95
131
  // In-process delegation: the inner call resolves the real adapter and
96
- // runs its full pipeline (retry, waterfall, replay-state stripping).
97
- yield* this.facts.llm().stream({ ...options, provider: route.provider })
132
+ // runs its full pipeline (retry, waterfall, replay-state stripping). The
133
+ // model is rewritten to the provider's raw local id — the merged display
134
+ // id may have had its vendor prefix stripped.
135
+ yield* this.facts.llm().stream({
136
+ ...options,
137
+ provider: route.provider,
138
+ model: entryRawId(merged, route.provider),
139
+ })
98
140
  }
99
141
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Provider discovery for the router: every registered LLM provider route
3
- * (minus the router itself) becomes a ProviderInfo, models merge by exact id
4
- * through the shared core, and per-provider credential status is detected
5
- * generically through the configurable-provider directory + the credentials
6
- * seam.
3
+ * (minus the router itself) becomes a ProviderInfo, models merge through the
4
+ * shared core (by exact id, or by prefix-stripped id when `ignorePrefix` is
5
+ * on), and per-provider credential status is detected generically through the
6
+ * configurable-provider directory + the credentials seam.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import { credentialRef, type CredentialProvider } from '@deepseek-ai/dsh-credentials'
@@ -16,7 +16,7 @@ export interface RouterCatalog {
16
16
  providers: ProviderInfo[]
17
17
  /** Provider id → human display name (configurable-directory name when known). */
18
18
  providerNames: Map<string, string>
19
- /** Models merged by exact id across providers. */
19
+ /** Models merged across providers (exact id, or stripped when flag on). */
20
20
  models: MergedModel[]
21
21
  /** Model id → merged model. */
22
22
  byId: Map<string, MergedModel>
@@ -89,7 +89,7 @@ async function detectCredentialConfigured(
89
89
  }
90
90
 
91
91
  /** Rebuild the live router catalog from the LLM registry. */
92
- export async function buildCatalog(ctx: Context): Promise<RouterCatalog> {
92
+ export async function buildCatalog(ctx: Context, ignorePrefix = false): Promise<RouterCatalog> {
93
93
  const routes = ctx.llm.listProviders().filter(provider => provider.id !== ROUTER_PROVIDER_ID)
94
94
  const directory = new Map<string, ConfigurableEntry>(
95
95
  ctx.llm.listConfigurableProviders().map(entry => [entry.provider, {
@@ -122,7 +122,7 @@ export async function buildCatalog(ctx: Context): Promise<RouterCatalog> {
122
122
  credentialConfigured.set(route.id, await detectCredentialConfigured(ctx, entry))
123
123
  }
124
124
 
125
- const models = mergeModels(providers)
125
+ const models = mergeModels(providers, { ignorePrefix })
126
126
  const byId = new Map(models.map(model => [model.id, model]))
127
127
  return { providers, providerNames, models, byId, credentialConfigured }
128
128
  }
package/src/server/rpc.ts CHANGED
@@ -134,6 +134,22 @@ export function registerApiRoutes(ctx: Context, service: ModelRouterService): ()
134
134
  return service.state()
135
135
  })
136
136
 
137
+ route('set-show-quick-switch', async (p) => {
138
+ if (typeof p.value !== 'boolean') {
139
+ throw new Error('value 必须是布尔值')
140
+ }
141
+ await service.setShowQuickSwitch(p.value)
142
+ return service.state()
143
+ })
144
+
145
+ route('set-ignore-model-id-prefix', async (p) => {
146
+ if (typeof p.value !== 'boolean') {
147
+ throw new Error('value 必须是布尔值')
148
+ }
149
+ await service.setIgnoreModelIdPrefix(p.value)
150
+ return service.state()
151
+ })
152
+
137
153
  return () => {
138
154
  for (const dispose of disposers) dispose()
139
155
  }
@@ -10,12 +10,12 @@ import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
10
10
  import {
11
11
  initialConfigFor,
12
12
  normalizeConfig,
13
+ reconcileConfig,
13
14
  ROUTER_PROVIDER_ID,
14
15
  ROUTER_SETTINGS_NS,
15
16
  resolveActive,
16
17
  setActive,
17
18
  setOrder,
18
- syncConfig,
19
19
  type RouterConfigShape,
20
20
  } from '../shared/config.js'
21
21
  import { buildCatalog, type RouterCatalog } from './catalog.js'
@@ -29,12 +29,20 @@ export const RouterConfigSchema: z<RouterConfigShape> = z.object({
29
29
  order: z.array(z.string()),
30
30
  active: z.string(),
31
31
  })),
32
+ // Older documents predate the toggles; normalizeConfig defaults both on.
33
+ // Schemastery object keys are input-optional, so absent is valid here.
34
+ showQuickSwitch: z.boolean(),
35
+ ignoreModelIdPrefix: z.boolean(),
32
36
  })
33
37
 
34
- const EMPTY_CONFIG: RouterConfigShape = { models: {} }
38
+ const EMPTY_CONFIG: RouterConfigShape = { models: {}, showQuickSwitch: true, ignoreModelIdPrefix: true }
35
39
 
36
40
  /** The settings-page state view served by the API. */
37
41
  export interface ModelRouterState {
42
+ /** Whether the composer quick route-switcher button is enabled. */
43
+ showQuickSwitch: boolean
44
+ /** Whether model ids are matched with their leading vendor/ prefix ignored. */
45
+ ignoreModelIdPrefix: boolean
38
46
  providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
39
47
  models: Array<{
40
48
  id: string
@@ -119,7 +127,7 @@ export class ModelRouterService implements RouterFacts {
119
127
  /** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
120
128
  refreshCatalog(): Promise<RouterCatalog> {
121
129
  if (this.inflight !== null) return this.inflight
122
- this.inflight = buildCatalog(this.ctx)
130
+ this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix)
123
131
  .then((catalog) => {
124
132
  this.cached = catalog
125
133
  return catalog
@@ -134,32 +142,19 @@ export class ModelRouterService implements RouterFacts {
134
142
  return this.inflight
135
143
  }
136
144
 
137
- /** Refresh, then persist any config drift (new providers/models, vanish). */
145
+ /** Refresh, then persist any config drift (migration, new/vanished models). */
138
146
  async refreshAndReconcile(): Promise<void> {
139
147
  const catalog = await this.refreshCatalog()
140
148
  if (this.scope === undefined) return
141
149
  const current = this.config()
142
- const next = { ...current.models }
143
- let changed = false
144
- for (const model of catalog.models) {
145
- const ids = model.providers.map(provider => provider.provider)
146
- const result = syncConfig(current, model.id, ids)
147
- if (result.changed) changed = true
148
- for (const [id, entry] of Object.entries(result.models)) {
149
- if (entry === undefined) delete next[id]
150
- else next[id] = entry
151
- }
152
- }
153
- // Drop stored entries whose model no longer exists anywhere.
154
- for (const id of Object.keys(next)) {
155
- if (!catalog.byId.has(id)) {
156
- delete next[id]
157
- changed = true
158
- }
159
- }
150
+ const { models, changed } = reconcileConfig(current, catalog.models)
160
151
  if (!changed) return
161
152
  try {
162
- await this.scope.replace({ models: next })
153
+ await this.scope.replace({
154
+ models,
155
+ showQuickSwitch: current.showQuickSwitch,
156
+ ignoreModelIdPrefix: current.ignoreModelIdPrefix,
157
+ })
163
158
  } catch (error) {
164
159
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
165
160
  }
@@ -181,6 +176,8 @@ export class ModelRouterService implements RouterFacts {
181
176
  const names = catalog.providerNames
182
177
  const creds = catalog.credentialConfigured
183
178
  return {
179
+ showQuickSwitch: config.showQuickSwitch,
180
+ ignoreModelIdPrefix: config.ignoreModelIdPrefix,
184
181
  providers: catalog.providers.map(provider => ({
185
182
  id: provider.id,
186
183
  name: names.get(provider.id) ?? provider.id,
@@ -230,10 +227,49 @@ export class ModelRouterService implements RouterFacts {
230
227
  await this.persist(models)
231
228
  }
232
229
 
230
+ /** Toggle the composer quick route-switcher button (global, persisted). */
231
+ async setShowQuickSwitch(value: boolean): Promise<void> {
232
+ if (this.scope === undefined) {
233
+ throw new Error('model-router: settings service is not available in this profile')
234
+ }
235
+ const current = this.config()
236
+ if (current.showQuickSwitch === value) return
237
+ await this.scope.replace({
238
+ models: current.models,
239
+ showQuickSwitch: value,
240
+ ignoreModelIdPrefix: current.ignoreModelIdPrefix,
241
+ })
242
+ }
243
+
244
+ /**
245
+ * Toggle prefix-ignored model matching (global, persisted). Persists the
246
+ * flag first, then rebuilds the catalog with the new merge rule and
247
+ * reconciles stored config (migrates prefixed keys to merged ids when
248
+ * turning on; split models re-initialize when turning off).
249
+ */
250
+ async setIgnoreModelIdPrefix(value: boolean): Promise<void> {
251
+ if (this.scope === undefined) {
252
+ throw new Error('model-router: settings service is not available in this profile')
253
+ }
254
+ const current = this.config()
255
+ if (current.ignoreModelIdPrefix === value) return
256
+ await this.scope.replace({
257
+ models: current.models,
258
+ showQuickSwitch: current.showQuickSwitch,
259
+ ignoreModelIdPrefix: value,
260
+ })
261
+ await this.refreshAndReconcile()
262
+ }
263
+
233
264
  private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
234
265
  if (this.scope === undefined) {
235
266
  throw new Error('model-router: settings service is not available in this profile')
236
267
  }
237
- await this.scope.replace({ models })
268
+ const current = this.config()
269
+ await this.scope.replace({
270
+ models,
271
+ showQuickSwitch: current.showQuickSwitch,
272
+ ignoreModelIdPrefix: current.ignoreModelIdPrefix,
273
+ })
238
274
  }
239
275
  }
@@ -5,7 +5,11 @@
5
5
  * (settings page rendering).
6
6
  *
7
7
  * Decided semantics (grilling session, all recommendations accepted):
8
- * - Strict merge: models are merged across providers by exact model id only.
8
+ * - Merge: models are merged across providers by model id — by exact id, or
9
+ * (when ignoreModelIdPrefix is on) by id with the first `vendor/` segment
10
+ * stripped, so `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` are the
11
+ * same model. Display always uses the merged (prefix-free) id; each provider
12
+ * entry keeps its own raw local id for delegation.
9
13
  * - Per-model provider config: an order[] (future auto-failover preference)
10
14
  * plus an independent active provider (what requests actually route to).
11
15
  * - First-seen provider order: for a never-configured model the initial
@@ -14,13 +18,18 @@
14
18
  * the first remaining provider automatically.
15
19
  * - New providers for a known model: appended at the end of order; active
16
20
  * untouched.
21
+ * - Prefix-ignore migration: stored config keyed by a now-stripped raw id
22
+ * moves to the merged id (unprefixed entry wins when both exist; order is a
23
+ * deduped union). Turning the flag off does not migrate back — the split
24
+ * models re-initialize from discovery order.
17
25
  */
18
26
 
19
27
  /** One provider's view of one model. */
20
28
  export interface ProviderModelInfo {
21
29
  /** Provider id the model belongs to. */
22
30
  provider: string
23
- /** Provider-local model id (== merged model id, strict merge). */
31
+ /** Provider-local raw model id, exactly as the provider advertises it
32
+ * (may carry a `vendor/` prefix; this is the id delegation must use). */
24
33
  id: string
25
34
  /** Provider-local display name. */
26
35
  name: string
@@ -38,14 +47,16 @@ export interface ProviderInfo {
38
47
  models: Array<Omit<ProviderModelInfo, 'provider'>>
39
48
  }
40
49
 
41
- /** One merged model across all providers (strict id merge). */
50
+ /** One merged model across all providers. */
42
51
  export interface MergedModel {
52
+ /** Merged display id — the raw id with the leading segment stripped when
53
+ * prefix-ignore is on, so it is always shown without a vendor prefix. */
43
54
  id: string
44
- /** Per-provider entries in provider discovery order. */
55
+ /** Per-provider entries in provider discovery order (raw local ids). */
45
56
  providers: ProviderModelInfo[]
46
57
  }
47
58
 
48
- /** Router configuration for one model. */
59
+ /** Router configuration for one model (keyed by merged id). */
49
60
  export interface RouterModelConfig {
50
61
  /** Provider ids in preference order (future auto-failover). */
51
62
  order: string[]
@@ -56,20 +67,69 @@ export interface RouterModelConfig {
56
67
  /** The whole router config document. */
57
68
  export interface RouterConfigShape {
58
69
  models: Record<string, RouterModelConfig>
70
+ /**
71
+ * Whether the quick route-switcher button is shown beside the composer
72
+ * model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
73
+ * globally; absent/unknown documents normalize to `true`.
74
+ */
75
+ showQuickSwitch: boolean
76
+ /**
77
+ * Whether model ids are matched with their leading `vendor/` segment
78
+ * ignored (settings → 模型路由 → 匹配时忽略模型 ID 前缀), so
79
+ * `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` merge into one model
80
+ * displayed as `deepseek-v4-flash`. Absent/unknown documents normalize to
81
+ * `true`.
82
+ */
83
+ ignoreModelIdPrefix: boolean
59
84
  }
60
85
 
61
86
  export const ROUTER_PROVIDER_ID = 'model-router'
62
87
  export const ROUTER_SETTINGS_NS = 'model-router'
63
88
 
64
- /** Merge every provider's models into one entry per exact model id. */
65
- export function mergeModels(providers: readonly ProviderInfo[]): MergedModel[] {
89
+ /**
90
+ * Strip the first path segment (`vendor/`) from a model id, case-sensitively.
91
+ * Inert for ids without a prefix: no slash, an empty first segment, or an
92
+ * empty remainder all return the input unchanged. `a/b/c` becomes `b/c`.
93
+ */
94
+ export function stripModelIdPrefix(id: string): string {
95
+ const slash = id.indexOf('/')
96
+ if (slash <= 0 || slash === id.length - 1) return id
97
+ return id.slice(slash + 1)
98
+ }
99
+
100
+ export interface MergeOptions {
101
+ /** Group by prefix-stripped id instead of the exact raw id. */
102
+ ignorePrefix?: boolean
103
+ }
104
+
105
+ /**
106
+ * Merge every provider's models into one entry per model id. With
107
+ * `ignorePrefix` the group key is the prefix-stripped id and the merged id is
108
+ * that stripped id, while each provider entry keeps its raw local id. When a
109
+ * single provider advertises both a prefixed and the unprefixed raw id for the
110
+ * same group (a same-provider collision), the unprefixed raw id wins (it is
111
+ * the canonical form); otherwise the first-seen raw id wins.
112
+ */
113
+ export function mergeModels(providers: readonly ProviderInfo[], options: MergeOptions = {}): MergedModel[] {
66
114
  const byId = new Map<string, MergedModel>()
67
115
  for (const provider of providers) {
116
+ // Choose one raw id per group key for this provider, preferring the
117
+ // unprefixed one when both exist.
118
+ const chosen = new Map<string, string>()
68
119
  for (const model of provider.models) {
69
- let merged = byId.get(model.id)
120
+ const key = options.ignorePrefix ? stripModelIdPrefix(model.id) : model.id
121
+ const prev = chosen.get(key)
122
+ if (prev === undefined || (prev !== key && model.id === key)) {
123
+ chosen.set(key, model.id)
124
+ }
125
+ }
126
+ for (const model of provider.models) {
127
+ const key = options.ignorePrefix ? stripModelIdPrefix(model.id) : model.id
128
+ if (chosen.get(key) !== model.id) continue
129
+ let merged = byId.get(key)
70
130
  if (merged === undefined) {
71
- merged = { id: model.id, providers: [] }
72
- byId.set(model.id, merged)
131
+ merged = { id: key, providers: [] }
132
+ byId.set(key, merged)
73
133
  }
74
134
  merged.providers.push({
75
135
  provider: provider.id,
@@ -172,6 +232,83 @@ export function syncConfig(
172
232
  return { models: next, changed }
173
233
  }
174
234
 
235
+ function sameModels(a: Record<string, RouterModelConfig>, b: Record<string, RouterModelConfig>): boolean {
236
+ const aKeys = Object.keys(a)
237
+ const bKeys = Object.keys(b)
238
+ if (aKeys.length !== bKeys.length) return false
239
+ for (const key of aKeys) {
240
+ const x = a[key]
241
+ const y = b[key]
242
+ if (y === undefined || x.active !== y.active || x.order.length !== y.order.length) return false
243
+ if (!x.order.every((id, i) => id === y.order[i])) return false
244
+ }
245
+ return true
246
+ }
247
+
248
+ /**
249
+ * Reconcile the stored config document against the live merged catalog.
250
+ *
251
+ * Beyond the per-model sync of {@link syncConfig}, this remaps stored entries
252
+ * to their merged ids: with prefix-ignore on, an entry keyed by a raw id that
253
+ * now strips to a merged id (e.g. `deepseek/deepseek-v4-flash` →
254
+ * `deepseek-v4-flash`) migrates to that merged id. When several stored keys
255
+ * collapse onto one merged id, the entry keyed by the merged id itself wins
256
+ * for `active`, and `order` becomes the deduped union in exact-key-first
257
+ * order. Stored keys no longer mapping to any merged model are dropped (this
258
+ * also covers turning the flag off — split models re-initialize fresh).
259
+ */
260
+ export function reconcileConfig(
261
+ config: RouterConfigShape,
262
+ models: readonly MergedModel[],
263
+ ): { models: Record<string, RouterModelConfig>; changed: boolean } {
264
+ // stored key → merged id it belongs to (exact key wins; prefixed raw keys
265
+ // map to their stripped merged id only while the flag is on)
266
+ const keyToMerged = new Map<string, string>()
267
+ for (const model of models) {
268
+ keyToMerged.set(model.id, model.id)
269
+ if (!config.ignoreModelIdPrefix) continue
270
+ for (const provider of model.providers) {
271
+ if (stripModelIdPrefix(provider.id) === model.id && !keyToMerged.has(provider.id)) {
272
+ keyToMerged.set(provider.id, model.id)
273
+ }
274
+ }
275
+ }
276
+
277
+ const grouped = new Map<string, Array<{ key: string; entry: RouterModelConfig }>>()
278
+ for (const [key, entry] of Object.entries(config.models)) {
279
+ const mergedId = keyToMerged.get(key)
280
+ if (mergedId === undefined) continue // stale: dropped from the next doc
281
+ const list = grouped.get(mergedId) ?? []
282
+ list.push({ key, entry })
283
+ grouped.set(mergedId, list)
284
+ }
285
+
286
+ const next: Record<string, RouterModelConfig> = {}
287
+ for (const model of models) {
288
+ const ids = model.providers.map(provider => provider.provider)
289
+ const list = grouped.get(model.id)
290
+ let base: RouterModelConfig | undefined
291
+ if (list !== undefined && list.length > 0) {
292
+ const exact = list.find(group => group.key === model.id)
293
+ const ordered = exact === undefined ? list : [exact, ...list.filter(group => group.key !== model.id)]
294
+ const order: string[] = []
295
+ for (const group of ordered) {
296
+ for (const id of group.entry.order) {
297
+ if (!order.includes(id)) order.push(id)
298
+ }
299
+ }
300
+ base = { order, active: exact?.entry.active ?? ordered[0].entry.active }
301
+ }
302
+ const view: RouterConfigShape = { ...config, models: { ...next } }
303
+ if (base !== undefined) view.models[model.id] = base
304
+ const result = syncConfig(view, model.id, ids)
305
+ const entry = result.models[model.id]
306
+ if (entry !== undefined) next[model.id] = entry
307
+ }
308
+
309
+ return { models: next, changed: !sameModels(next, config.models) }
310
+ }
311
+
175
312
  /** Switch a model's active provider (no-op when already active). */
176
313
  export function setActive(
177
314
  config: RouterConfigShape,
@@ -216,9 +353,14 @@ export function setOrder(
216
353
  /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
217
354
  export function normalizeConfig(value: unknown): RouterConfigShape {
218
355
  const models: Record<string, RouterModelConfig> = {}
219
- if (typeof value !== 'object' || value === null) return { models }
220
- const raw = (value as { models?: unknown }).models
221
- if (typeof raw !== 'object' || raw === null) return { models }
356
+ if (typeof value !== 'object' || value === null) {
357
+ return { models, showQuickSwitch: true, ignoreModelIdPrefix: true }
358
+ }
359
+ const root = value as { models?: unknown; showQuickSwitch?: unknown; ignoreModelIdPrefix?: unknown }
360
+ const showQuickSwitch = root.showQuickSwitch !== false
361
+ const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false
362
+ const raw = root.models
363
+ if (typeof raw !== 'object' || raw === null) return { models, showQuickSwitch, ignoreModelIdPrefix }
222
364
  for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
223
365
  if (typeof entry !== 'object' || entry === null) continue
224
366
  const e = entry as { order?: unknown; active?: unknown }
@@ -227,5 +369,5 @@ export function normalizeConfig(value: unknown): RouterConfigShape {
227
369
  if (active === '') continue
228
370
  models[id] = { order, active }
229
371
  }
230
- return { models }
372
+ return { models, showQuickSwitch, ignoreModelIdPrefix }
231
373
  }