@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.
- package/README.md +2 -0
- package/lib/client.js +48 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +198 -51
- 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 +10 -1
- package/lib/types/shared/config.d.ts +59 -7
- package/package.json +2 -1
- package/src/client/api.ts +4 -0
- package/src/client/locales.ts +4 -0
- package/src/client/settings-section.tsx +29 -0
- package/src/server/adapter.ts +52 -10
- package/src/server/catalog.ts +7 -7
- package/src/server/rpc.ts +8 -0
- package/src/server/service.ts +46 -26
- package/src/shared/config.ts +148 -14
|
@@ -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}>
|
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
|
@@ -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
|
}
|
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,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
|
|
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
|
|
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
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
}
|
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[]
|
|
@@ -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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
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:
|
|
78
|
-
byId.set(
|
|
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)
|
|
226
|
-
|
|
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
|
}
|