@fonlan/dsh-model-router 0.2.0 → 0.3.0

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.
@@ -59,6 +59,7 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
59
59
  const [busyModel, setBusyModel] = useState<string | null>(null)
60
60
  const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
61
61
  const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
62
+ const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
62
63
 
63
64
  const load = useCallback(async () => {
64
65
  setLoading(true)
@@ -127,6 +128,22 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
127
128
  }
128
129
  }, [state])
129
130
 
131
+ const toggleIgnorePrefix = useCallback(async (value: boolean) => {
132
+ if (state === null) return
133
+ const previous = state.ignoreModelIdPrefix
134
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
135
+ setIgnorePrefixBusy(true)
136
+ try {
137
+ setState(await api.setIgnoreModelIdPrefix(value))
138
+ setError(null)
139
+ } catch (cause) {
140
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
141
+ setError(cause instanceof Error ? cause.message : String(cause))
142
+ } finally {
143
+ setIgnorePrefixBusy(false)
144
+ }
145
+ }, [state])
146
+
130
147
  const models = useMemo(() => state?.models ?? [], [state])
131
148
 
132
149
  return (
@@ -144,6 +161,18 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
144
161
  <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
145
162
  </span>
146
163
  </label>
164
+ <label className="mr-quick-switch">
165
+ <input
166
+ type="checkbox"
167
+ checked={state?.ignoreModelIdPrefix ?? true}
168
+ disabled={loading || ignorePrefixBusy}
169
+ onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
170
+ />
171
+ <span className="mr-quick-switch-copy">
172
+ <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
173
+ <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
174
+ </span>
175
+ </label>
147
176
  <div className="mr-toolbar">
148
177
  <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
149
178
  <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
@@ -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
@@ -142,6 +142,14 @@ export function registerApiRoutes(ctx: Context, service: ModelRouterService): ()
142
142
  return service.state()
143
143
  })
144
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
+
145
153
  return () => {
146
154
  for (const dispose of disposers) dispose()
147
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,17 +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 toggle; normalizeConfig defaults to true.
32
+ // Older documents predate the toggles; normalizeConfig defaults both on.
33
33
  // Schemastery object keys are input-optional, so absent is valid here.
34
34
  showQuickSwitch: z.boolean(),
35
+ ignoreModelIdPrefix: z.boolean(),
35
36
  })
36
37
 
37
- const EMPTY_CONFIG: RouterConfigShape = { models: {}, showQuickSwitch: true }
38
+ const EMPTY_CONFIG: RouterConfigShape = { models: {}, showQuickSwitch: true, ignoreModelIdPrefix: true }
38
39
 
39
40
  /** The settings-page state view served by the API. */
40
41
  export interface ModelRouterState {
41
42
  /** Whether the composer quick route-switcher button is enabled. */
42
43
  showQuickSwitch: boolean
44
+ /** Whether model ids are matched with their leading vendor/ prefix ignored. */
45
+ ignoreModelIdPrefix: boolean
43
46
  providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
44
47
  models: Array<{
45
48
  id: string
@@ -124,7 +127,7 @@ export class ModelRouterService implements RouterFacts {
124
127
  /** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
125
128
  refreshCatalog(): Promise<RouterCatalog> {
126
129
  if (this.inflight !== null) return this.inflight
127
- this.inflight = buildCatalog(this.ctx)
130
+ this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix)
128
131
  .then((catalog) => {
129
132
  this.cached = catalog
130
133
  return catalog
@@ -139,32 +142,19 @@ export class ModelRouterService implements RouterFacts {
139
142
  return this.inflight
140
143
  }
141
144
 
142
- /** Refresh, then persist any config drift (new providers/models, vanish). */
145
+ /** Refresh, then persist any config drift (migration, new/vanished models). */
143
146
  async refreshAndReconcile(): Promise<void> {
144
147
  const catalog = await this.refreshCatalog()
145
148
  if (this.scope === undefined) return
146
149
  const current = this.config()
147
- const next = { ...current.models }
148
- let changed = false
149
- for (const model of catalog.models) {
150
- const ids = model.providers.map(provider => provider.provider)
151
- const result = syncConfig(current, model.id, ids)
152
- if (result.changed) changed = true
153
- for (const [id, entry] of Object.entries(result.models)) {
154
- if (entry === undefined) delete next[id]
155
- else next[id] = entry
156
- }
157
- }
158
- // Drop stored entries whose model no longer exists anywhere.
159
- for (const id of Object.keys(next)) {
160
- if (!catalog.byId.has(id)) {
161
- delete next[id]
162
- changed = true
163
- }
164
- }
150
+ const { models, changed } = reconcileConfig(current, catalog.models)
165
151
  if (!changed) return
166
152
  try {
167
- await this.scope.replace({ models: next, showQuickSwitch: this.config().showQuickSwitch })
153
+ await this.scope.replace({
154
+ models,
155
+ showQuickSwitch: current.showQuickSwitch,
156
+ ignoreModelIdPrefix: current.ignoreModelIdPrefix,
157
+ })
168
158
  } catch (error) {
169
159
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
170
160
  }
@@ -187,6 +177,7 @@ export class ModelRouterService implements RouterFacts {
187
177
  const creds = catalog.credentialConfigured
188
178
  return {
189
179
  showQuickSwitch: config.showQuickSwitch,
180
+ ignoreModelIdPrefix: config.ignoreModelIdPrefix,
190
181
  providers: catalog.providers.map(provider => ({
191
182
  id: provider.id,
192
183
  name: names.get(provider.id) ?? provider.id,
@@ -243,13 +234,42 @@ export class ModelRouterService implements RouterFacts {
243
234
  }
244
235
  const current = this.config()
245
236
  if (current.showQuickSwitch === value) return
246
- await this.scope.replace({ models: current.models, showQuickSwitch: value })
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()
247
262
  }
248
263
 
249
264
  private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
250
265
  if (this.scope === undefined) {
251
266
  throw new Error('model-router: settings service is not available in this profile')
252
267
  }
253
- await this.scope.replace({ models, showQuickSwitch: this.config().showQuickSwitch })
268
+ const current = this.config()
269
+ await this.scope.replace({
270
+ models,
271
+ showQuickSwitch: current.showQuickSwitch,
272
+ ignoreModelIdPrefix: current.ignoreModelIdPrefix,
273
+ })
254
274
  }
255
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[]
@@ -62,20 +73,63 @@ export interface RouterConfigShape {
62
73
  * globally; absent/unknown documents normalize to `true`.
63
74
  */
64
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
65
84
  }
66
85
 
67
86
  export const ROUTER_PROVIDER_ID = 'model-router'
68
87
  export const ROUTER_SETTINGS_NS = 'model-router'
69
88
 
70
- /** Merge every provider's models into one entry per exact model id. */
71
- 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[] {
72
114
  const byId = new Map<string, MergedModel>()
73
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>()
119
+ for (const model of provider.models) {
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
+ }
74
126
  for (const model of provider.models) {
75
- let merged = byId.get(model.id)
127
+ const key = options.ignorePrefix ? stripModelIdPrefix(model.id) : model.id
128
+ if (chosen.get(key) !== model.id) continue
129
+ let merged = byId.get(key)
76
130
  if (merged === undefined) {
77
- merged = { id: model.id, providers: [] }
78
- byId.set(model.id, merged)
131
+ merged = { id: key, providers: [] }
132
+ byId.set(key, merged)
79
133
  }
80
134
  merged.providers.push({
81
135
  provider: provider.id,
@@ -178,6 +232,83 @@ export function syncConfig(
178
232
  return { models: next, changed }
179
233
  }
180
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
+
181
312
  /** Switch a model's active provider (no-op when already active). */
182
313
  export function setActive(
183
314
  config: RouterConfigShape,
@@ -222,11 +353,14 @@ export function setOrder(
222
353
  /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
223
354
  export function normalizeConfig(value: unknown): RouterConfigShape {
224
355
  const models: Record<string, RouterModelConfig> = {}
225
- if (typeof value !== 'object' || value === null) return { models, showQuickSwitch: true }
226
- const root = value as { models?: unknown; showQuickSwitch?: unknown }
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 }
227
360
  const showQuickSwitch = root.showQuickSwitch !== false
361
+ const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false
228
362
  const raw = root.models
229
- if (typeof raw !== 'object' || raw === null) return { models, showQuickSwitch }
363
+ if (typeof raw !== 'object' || raw === null) return { models, showQuickSwitch, ignoreModelIdPrefix }
230
364
  for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
231
365
  if (typeof entry !== 'object' || entry === null) continue
232
366
  const e = entry as { order?: unknown; active?: unknown }
@@ -235,5 +369,5 @@ export function normalizeConfig(value: unknown): RouterConfigShape {
235
369
  if (active === '') continue
236
370
  models[id] = { order, active }
237
371
  }
238
- return { models, showQuickSwitch }
372
+ return { models, showQuickSwitch, ignoreModelIdPrefix }
239
373
  }