@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.
@@ -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
+ }