@fonlan/dsh-model-router 0.1.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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +368 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +626 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/server/adapter.d.ts +42 -0
- package/lib/types/server/catalog.d.ts +24 -0
- package/lib/types/server/rpc.d.ts +3 -0
- package/lib/types/server/service.d.ts +66 -0
- package/lib/types/shared/config.d.ts +92 -0
- package/package.json +99 -0
- package/src/client/api.ts +56 -0
- package/src/client/index.tsx +35 -0
- package/src/client/locales.ts +39 -0
- package/src/client/settings-section.css +210 -0
- package/src/client/settings-section.tsx +235 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +37 -0
- package/src/server/adapter.ts +99 -0
- package/src/server/catalog.ts +128 -0
- package/src/server/rpc.ts +147 -0
- package/src/server/service.ts +239 -0
- package/src/shared/config.ts +231 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-router service: settings persistence, live catalog cache, the
|
|
3
|
+
* virtual adapter registration, topology-triggered reconciliation, and the
|
|
4
|
+
* mutation actions the API surface exposes.
|
|
5
|
+
*/
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
9
|
+
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import {
|
|
11
|
+
initialConfigFor,
|
|
12
|
+
normalizeConfig,
|
|
13
|
+
ROUTER_PROVIDER_ID,
|
|
14
|
+
ROUTER_SETTINGS_NS,
|
|
15
|
+
resolveActive,
|
|
16
|
+
setActive,
|
|
17
|
+
setOrder,
|
|
18
|
+
syncConfig,
|
|
19
|
+
type RouterConfigShape,
|
|
20
|
+
} from '../shared/config.js'
|
|
21
|
+
import { buildCatalog, type RouterCatalog } from './catalog.js'
|
|
22
|
+
import { ModelRouterAdapter, type RouterFacts } from './adapter.js'
|
|
23
|
+
|
|
24
|
+
export const NS = settingsNamespace(ROUTER_SETTINGS_NS)
|
|
25
|
+
|
|
26
|
+
/** Settings document schema: one entry per model id. */
|
|
27
|
+
export const RouterConfigSchema: z<RouterConfigShape> = z.object({
|
|
28
|
+
models: z.dict(z.object({
|
|
29
|
+
order: z.array(z.string()),
|
|
30
|
+
active: z.string(),
|
|
31
|
+
})),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const EMPTY_CONFIG: RouterConfigShape = { models: {} }
|
|
35
|
+
|
|
36
|
+
/** The settings-page state view served by the API. */
|
|
37
|
+
export interface ModelRouterState {
|
|
38
|
+
providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
|
|
39
|
+
models: Array<{
|
|
40
|
+
id: string
|
|
41
|
+
/** Display name following the effective active provider. */
|
|
42
|
+
name: string
|
|
43
|
+
/** Effective active provider (never null while the model has providers). */
|
|
44
|
+
active: string | null
|
|
45
|
+
/** Effective provider preference order. */
|
|
46
|
+
order: string[]
|
|
47
|
+
providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
|
|
48
|
+
}>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ModelRouterService implements RouterFacts {
|
|
52
|
+
private readonly ctx: Context
|
|
53
|
+
private cached: RouterCatalog = {
|
|
54
|
+
providers: [],
|
|
55
|
+
providerNames: new Map(),
|
|
56
|
+
models: [],
|
|
57
|
+
byId: new Map(),
|
|
58
|
+
credentialConfigured: new Map(),
|
|
59
|
+
}
|
|
60
|
+
private inflight: Promise<RouterCatalog> | null = null
|
|
61
|
+
private scope: SettingsScope<RouterConfigShape> | undefined
|
|
62
|
+
private registration: AdapterRegistrationHandle | undefined
|
|
63
|
+
|
|
64
|
+
constructor(ctx: Context) {
|
|
65
|
+
this.ctx = ctx
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Register settings + adapter and wire topology listeners. */
|
|
69
|
+
start(): void {
|
|
70
|
+
// Optional settings service: without one the router still routes
|
|
71
|
+
// (config falls back to catalog order) but cannot persist.
|
|
72
|
+
this.ctx.inject(['settings'], (sctx) => {
|
|
73
|
+
this.scope = sctx.settings.register(NS, RouterConfigSchema) as SettingsScope<RouterConfigShape>
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const adapter = new ModelRouterAdapter(this)
|
|
77
|
+
this.registration = this.ctx.llm.registerAdapter([ROUTER_PROVIDER_ID], adapter)
|
|
78
|
+
|
|
79
|
+
// Provider topology changes (added/removed providers, catalog swaps)
|
|
80
|
+
// invalidate the cache and reconcile the stored document.
|
|
81
|
+
this.ctx.on('llm/adapters-updated', () => {
|
|
82
|
+
void this.refreshAndReconcile()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// First load + reconcile: never block plugin startup on the catalog.
|
|
86
|
+
void this.refreshAndReconcile()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stop(): void {
|
|
90
|
+
this.registration?.()
|
|
91
|
+
this.registration = undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── RouterFacts ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
catalog(): { models: RouterCatalog['models']; byId: RouterCatalog['byId'] } {
|
|
97
|
+
return { models: this.cached.models, byId: this.cached.byId }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
config(): RouterConfigShape {
|
|
101
|
+
if (this.scope === undefined) return EMPTY_CONFIG
|
|
102
|
+
try {
|
|
103
|
+
return normalizeConfig(this.scope.get())
|
|
104
|
+
} catch {
|
|
105
|
+
return EMPTY_CONFIG
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
providerNames(): ReadonlyMap<string, string> {
|
|
110
|
+
return this.cached.providerNames
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
llm() {
|
|
114
|
+
return this.ctx.llm
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── catalog lifecycle ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
120
|
+
refreshCatalog(): Promise<RouterCatalog> {
|
|
121
|
+
if (this.inflight !== null) return this.inflight
|
|
122
|
+
this.inflight = buildCatalog(this.ctx)
|
|
123
|
+
.then((catalog) => {
|
|
124
|
+
this.cached = catalog
|
|
125
|
+
return catalog
|
|
126
|
+
})
|
|
127
|
+
.catch((error) => {
|
|
128
|
+
this.ctx.logger.warn(`model-router: catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
129
|
+
return this.cached
|
|
130
|
+
})
|
|
131
|
+
.finally(() => {
|
|
132
|
+
this.inflight = null
|
|
133
|
+
})
|
|
134
|
+
return this.inflight
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Refresh, then persist any config drift (new providers/models, vanish). */
|
|
138
|
+
async refreshAndReconcile(): Promise<void> {
|
|
139
|
+
const catalog = await this.refreshCatalog()
|
|
140
|
+
if (this.scope === undefined) return
|
|
141
|
+
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
|
+
}
|
|
160
|
+
if (!changed) return
|
|
161
|
+
try {
|
|
162
|
+
await this.scope.replace({ models: next })
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── actions ──────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** Effective active provider for one model id (null when unroutable). */
|
|
171
|
+
activeFor(modelId: string): string | null {
|
|
172
|
+
const ids = this.cached.byId.get(modelId)?.providers.map(provider => provider.provider) ?? []
|
|
173
|
+
return resolveActive(this.config(), modelId, ids)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The complete state view for the settings page. */
|
|
177
|
+
async state(): Promise<ModelRouterState> {
|
|
178
|
+
await this.refreshCatalog()
|
|
179
|
+
const catalog = this.cached
|
|
180
|
+
const config = this.config()
|
|
181
|
+
const names = catalog.providerNames
|
|
182
|
+
const creds = catalog.credentialConfigured
|
|
183
|
+
return {
|
|
184
|
+
providers: catalog.providers.map(provider => ({
|
|
185
|
+
id: provider.id,
|
|
186
|
+
name: names.get(provider.id) ?? provider.id,
|
|
187
|
+
credentialConfigured: creds.get(provider.id) ?? true,
|
|
188
|
+
})),
|
|
189
|
+
models: catalog.models.map((model) => {
|
|
190
|
+
const ids = model.providers.map(provider => provider.provider)
|
|
191
|
+
const stored = config.models[model.id] ?? initialConfigFor(ids)
|
|
192
|
+
const active = resolveActive(config, model.id, ids)
|
|
193
|
+
const activeEntry = model.providers.find(provider => provider.provider === active) ?? model.providers[0]
|
|
194
|
+
return {
|
|
195
|
+
id: model.id,
|
|
196
|
+
name: activeEntry?.name ?? model.id,
|
|
197
|
+
active,
|
|
198
|
+
order: stored?.order ?? [...ids],
|
|
199
|
+
providers: model.providers.map(provider => ({
|
|
200
|
+
id: provider.provider,
|
|
201
|
+
name: names.get(provider.provider) ?? provider.provider,
|
|
202
|
+
credentialConfigured: creds.get(provider.provider) ?? true,
|
|
203
|
+
})),
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Switch the active provider for one model (persisted, live on next request). */
|
|
210
|
+
async switchActive(modelId: string, providerId: string): Promise<void> {
|
|
211
|
+
await this.refreshCatalog()
|
|
212
|
+
const ids = this.cached.byId.get(modelId)?.providers.map(provider => provider.provider)
|
|
213
|
+
if (ids === undefined || ids.length === 0) {
|
|
214
|
+
throw new Error(`model-router: model "${modelId}" is not served by any provider`)
|
|
215
|
+
}
|
|
216
|
+
const { models, changed } = setActive(this.config(), modelId, providerId, ids)
|
|
217
|
+
if (!changed) return
|
|
218
|
+
await this.persist(models)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Replace the preference order for one model (active left untouched). */
|
|
222
|
+
async reorder(modelId: string, order: string[]): Promise<void> {
|
|
223
|
+
await this.refreshCatalog()
|
|
224
|
+
const ids = this.cached.byId.get(modelId)?.providers.map(provider => provider.provider)
|
|
225
|
+
if (ids === undefined || ids.length === 0) {
|
|
226
|
+
throw new Error(`model-router: model "${modelId}" is not served by any provider`)
|
|
227
|
+
}
|
|
228
|
+
const { models, changed } = setOrder(this.config(), modelId, order, ids)
|
|
229
|
+
if (!changed) return
|
|
230
|
+
await this.persist(models)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
|
|
234
|
+
if (this.scope === undefined) {
|
|
235
|
+
throw new Error('model-router: settings service is not available in this profile')
|
|
236
|
+
}
|
|
237
|
+
await this.scope.replace({ models })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-model-router shared routing core — pure functions, no side
|
|
3
|
+
* effects, unit-tested in test/config.test.ts and shared verbatim between the
|
|
4
|
+
* server half (route resolution at request time) and the client bundle
|
|
5
|
+
* (settings page rendering).
|
|
6
|
+
*
|
|
7
|
+
* Decided semantics (grilling session, all recommendations accepted):
|
|
8
|
+
* - Strict merge: models are merged across providers by exact model id only.
|
|
9
|
+
* - Per-model provider config: an order[] (future auto-failover preference)
|
|
10
|
+
* plus an independent active provider (what requests actually route to).
|
|
11
|
+
* - First-seen provider order: for a never-configured model the initial
|
|
12
|
+
* order is provider discovery order and the initial active is its head.
|
|
13
|
+
* - Vanished providers: pruned from order; when active vanishes, fall back to
|
|
14
|
+
* the first remaining provider automatically.
|
|
15
|
+
* - New providers for a known model: appended at the end of order; active
|
|
16
|
+
* untouched.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One provider's view of one model. */
|
|
20
|
+
export interface ProviderModelInfo {
|
|
21
|
+
/** Provider id the model belongs to. */
|
|
22
|
+
provider: string
|
|
23
|
+
/** Provider-local model id (== merged model id, strict merge). */
|
|
24
|
+
id: string
|
|
25
|
+
/** Provider-local display name. */
|
|
26
|
+
name: string
|
|
27
|
+
/** Provider-local context window (tokens), when advertised. */
|
|
28
|
+
contextWindow?: number
|
|
29
|
+
/** Provider-local max output tokens, when advertised. */
|
|
30
|
+
maxTokens?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One configured provider as discovered from the host. */
|
|
34
|
+
export interface ProviderInfo {
|
|
35
|
+
id: string
|
|
36
|
+
/** Provider display name when advertised. */
|
|
37
|
+
name?: string
|
|
38
|
+
models: Array<Omit<ProviderModelInfo, 'provider'>>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One merged model across all providers (strict id merge). */
|
|
42
|
+
export interface MergedModel {
|
|
43
|
+
id: string
|
|
44
|
+
/** Per-provider entries in provider discovery order. */
|
|
45
|
+
providers: ProviderModelInfo[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Router configuration for one model. */
|
|
49
|
+
export interface RouterModelConfig {
|
|
50
|
+
/** Provider ids in preference order (future auto-failover). */
|
|
51
|
+
order: string[]
|
|
52
|
+
/** The provider requests currently route to. */
|
|
53
|
+
active: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The whole router config document. */
|
|
57
|
+
export interface RouterConfigShape {
|
|
58
|
+
models: Record<string, RouterModelConfig>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ROUTER_PROVIDER_ID = 'model-router'
|
|
62
|
+
export const ROUTER_SETTINGS_NS = 'model-router'
|
|
63
|
+
|
|
64
|
+
/** Merge every provider's models into one entry per exact model id. */
|
|
65
|
+
export function mergeModels(providers: readonly ProviderInfo[]): MergedModel[] {
|
|
66
|
+
const byId = new Map<string, MergedModel>()
|
|
67
|
+
for (const provider of providers) {
|
|
68
|
+
for (const model of provider.models) {
|
|
69
|
+
let merged = byId.get(model.id)
|
|
70
|
+
if (merged === undefined) {
|
|
71
|
+
merged = { id: model.id, providers: [] }
|
|
72
|
+
byId.set(model.id, merged)
|
|
73
|
+
}
|
|
74
|
+
merged.providers.push({
|
|
75
|
+
provider: provider.id,
|
|
76
|
+
id: model.id,
|
|
77
|
+
name: model.name,
|
|
78
|
+
contextWindow: model.contextWindow,
|
|
79
|
+
maxTokens: model.maxTokens,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...byId.values()]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The provider view a merged model should display for (follows active). */
|
|
87
|
+
export function displayProvider(
|
|
88
|
+
merged: MergedModel,
|
|
89
|
+
active: string | undefined,
|
|
90
|
+
): ProviderModelInfo | null {
|
|
91
|
+
if (merged.providers.length === 0) return null
|
|
92
|
+
return merged.providers.find(p => p.provider === active) ?? merged.providers[0]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Initial config for a model the user never configured: first in order wins. */
|
|
96
|
+
export function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null {
|
|
97
|
+
if (providerIds.length === 0) return null
|
|
98
|
+
return { order: [...providerIds], active: providerIds[0] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Effective active provider for a model id.
|
|
103
|
+
*
|
|
104
|
+
* Rules: a stored active still present in the catalog wins; otherwise the
|
|
105
|
+
* first stored order entry still present wins; otherwise the first provider
|
|
106
|
+
* of the catalog wins; an empty catalog yields null.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveActive(
|
|
109
|
+
config: RouterConfigShape,
|
|
110
|
+
modelId: string,
|
|
111
|
+
catalogProviderIds: readonly string[],
|
|
112
|
+
): string | null {
|
|
113
|
+
if (catalogProviderIds.length === 0) return null
|
|
114
|
+
const stored = config.models[modelId]
|
|
115
|
+
if (stored !== undefined) {
|
|
116
|
+
if (catalogProviderIds.includes(stored.active)) return stored.active
|
|
117
|
+
const firstRemaining = stored.order.find(id => catalogProviderIds.includes(id))
|
|
118
|
+
if (firstRemaining !== undefined) return firstRemaining
|
|
119
|
+
}
|
|
120
|
+
return catalogProviderIds[0]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reconcile one model's config against the live catalog: prune vanished
|
|
125
|
+
* providers, append newly discovered ones, and repair a vanished active by
|
|
126
|
+
* falling back to the first remaining provider. Returns the new config, or
|
|
127
|
+
* undefined when the catalog leaves nothing to route to.
|
|
128
|
+
*/
|
|
129
|
+
export function syncConfig(
|
|
130
|
+
config: RouterConfigShape,
|
|
131
|
+
modelId: string,
|
|
132
|
+
catalogProviderIds: readonly string[],
|
|
133
|
+
): { models: Record<string, RouterModelConfig>; changed: boolean } {
|
|
134
|
+
const next = { ...config.models }
|
|
135
|
+
let changed = false
|
|
136
|
+
|
|
137
|
+
const existing = next[modelId]
|
|
138
|
+
if (catalogProviderIds.length === 0) {
|
|
139
|
+
if (existing !== undefined) {
|
|
140
|
+
delete next[modelId]
|
|
141
|
+
changed = true
|
|
142
|
+
}
|
|
143
|
+
return { models: next, changed }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (existing === undefined) {
|
|
147
|
+
const initial = initialConfigFor(catalogProviderIds)
|
|
148
|
+
if (initial !== null) {
|
|
149
|
+
next[modelId] = initial
|
|
150
|
+
changed = true
|
|
151
|
+
}
|
|
152
|
+
return { models: next, changed }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const catalog = new Set(catalogProviderIds)
|
|
156
|
+
const order = existing.order.filter(id => catalog.has(id))
|
|
157
|
+
if (order.length !== existing.order.length) changed = true
|
|
158
|
+
for (const id of catalogProviderIds) {
|
|
159
|
+
if (!order.includes(id)) {
|
|
160
|
+
order.push(id)
|
|
161
|
+
changed = true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let active = existing.active
|
|
165
|
+
if (!catalog.has(active)) {
|
|
166
|
+
active = order[0]
|
|
167
|
+
changed = true
|
|
168
|
+
}
|
|
169
|
+
if (changed) {
|
|
170
|
+
next[modelId] = { order, active }
|
|
171
|
+
}
|
|
172
|
+
return { models: next, changed }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Switch a model's active provider (no-op when already active). */
|
|
176
|
+
export function setActive(
|
|
177
|
+
config: RouterConfigShape,
|
|
178
|
+
modelId: string,
|
|
179
|
+
providerId: string,
|
|
180
|
+
catalogProviderIds: readonly string[],
|
|
181
|
+
): { models: Record<string, RouterModelConfig>; changed: boolean } {
|
|
182
|
+
if (!catalogProviderIds.includes(providerId)) {
|
|
183
|
+
throw new Error(`provider ${providerId} is not in the catalog for model ${modelId}`)
|
|
184
|
+
}
|
|
185
|
+
const synced = syncConfig(config, modelId, catalogProviderIds)
|
|
186
|
+
const next = synced.models
|
|
187
|
+
const entry = next[modelId]
|
|
188
|
+
if (entry === undefined) return { models: next, changed: synced.changed }
|
|
189
|
+
if (entry.active === providerId) return { models: next, changed: synced.changed }
|
|
190
|
+
next[modelId] = { ...entry, active: providerId }
|
|
191
|
+
return { models: next, changed: true }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Replace a model's provider preference order (active left untouched). */
|
|
195
|
+
export function setOrder(
|
|
196
|
+
config: RouterConfigShape,
|
|
197
|
+
modelId: string,
|
|
198
|
+
order: readonly string[],
|
|
199
|
+
catalogProviderIds: readonly string[],
|
|
200
|
+
): { models: Record<string, RouterModelConfig>; changed: boolean } {
|
|
201
|
+
const catalog = new Set(catalogProviderIds)
|
|
202
|
+
const clean = order.filter(id => catalog.has(id))
|
|
203
|
+
for (const id of catalogProviderIds) {
|
|
204
|
+
if (!clean.includes(id)) clean.push(id)
|
|
205
|
+
}
|
|
206
|
+
const synced = syncConfig(config, modelId, catalogProviderIds)
|
|
207
|
+
const next = synced.models
|
|
208
|
+
const entry = next[modelId]
|
|
209
|
+
if (entry === undefined) return { models: next, changed: synced.changed }
|
|
210
|
+
const sameOrder = entry.order.length === clean.length && entry.order.every((id, i) => id === clean[i])
|
|
211
|
+
if (sameOrder) return { models: next, changed: synced.changed }
|
|
212
|
+
next[modelId] = { ...entry, order: [...clean] }
|
|
213
|
+
return { models: next, changed: true }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
217
|
+
export function normalizeConfig(value: unknown): RouterConfigShape {
|
|
218
|
+
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 }
|
|
222
|
+
for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
223
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
224
|
+
const e = entry as { order?: unknown; active?: unknown }
|
|
225
|
+
const order = Array.isArray(e.order) ? e.order.filter((x): x is string => typeof x === 'string') : []
|
|
226
|
+
const active = typeof e.active === 'string' ? e.active : order[0] ?? ''
|
|
227
|
+
if (active === '') continue
|
|
228
|
+
models[id] = { order, active }
|
|
229
|
+
}
|
|
230
|
+
return { models }
|
|
231
|
+
}
|