@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.
- package/README.md +4 -0
- package/lib/client.js +331 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +239 -53
- package/lib/types/server/adapter.d.ts +13 -1
- package/lib/types/server/catalog.d.ts +6 -6
- package/lib/types/server/service.d.ts +14 -1
- package/lib/types/shared/config.d.ts +65 -7
- package/package.json +8 -2
- package/src/client/api.ts +8 -0
- package/src/client/index.tsx +7 -4
- package/src/client/locales.ts +24 -0
- package/src/client/route-switcher.css +136 -0
- package/src/client/route-switcher.tsx +252 -0
- package/src/client/settings-section.css +35 -0
- package/src/client/settings-section.tsx +58 -0
- package/src/server/adapter.ts +52 -10
- package/src/server/catalog.ts +7 -7
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +60 -24
- package/src/shared/config.ts +156 -14
package/src/server/adapter.ts
CHANGED
|
@@ -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
|
|
48
|
-
if (merged ===
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/server/catalog.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
}
|
package/src/server/service.ts
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
}
|
package/src/shared/config.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* (settings page rendering).
|
|
6
6
|
*
|
|
7
7
|
* Decided semantics (grilling session, all recommendations accepted):
|
|
8
|
-
* -
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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:
|
|
72
|
-
byId.set(
|
|
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)
|
|
220
|
-
|
|
221
|
-
|
|
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
|
}
|