@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/lib/index.js
CHANGED
|
@@ -5,25 +5,52 @@ import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
|
5
5
|
//#region src/shared/config.ts
|
|
6
6
|
const ROUTER_PROVIDER_ID = "model-router";
|
|
7
7
|
const ROUTER_SETTINGS_NS = "model-router";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Strip the first path segment (`vendor/`) from a model id, case-sensitively.
|
|
10
|
+
* Inert for ids without a prefix: no slash, an empty first segment, or an
|
|
11
|
+
* empty remainder all return the input unchanged. `a/b/c` becomes `b/c`.
|
|
12
|
+
*/
|
|
13
|
+
function stripModelIdPrefix(id) {
|
|
14
|
+
const slash = id.indexOf("/");
|
|
15
|
+
if (slash <= 0 || slash === id.length - 1) return id;
|
|
16
|
+
return id.slice(slash + 1);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Merge every provider's models into one entry per model id. With
|
|
20
|
+
* `ignorePrefix` the group key is the prefix-stripped id and the merged id is
|
|
21
|
+
* that stripped id, while each provider entry keeps its raw local id. When a
|
|
22
|
+
* single provider advertises both a prefixed and the unprefixed raw id for the
|
|
23
|
+
* same group (a same-provider collision), the unprefixed raw id wins (it is
|
|
24
|
+
* the canonical form); otherwise the first-seen raw id wins.
|
|
25
|
+
*/
|
|
26
|
+
function mergeModels(providers, options = {}) {
|
|
10
27
|
const byId = /* @__PURE__ */ new Map();
|
|
11
|
-
for (const provider of providers)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
28
|
+
for (const provider of providers) {
|
|
29
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
30
|
+
for (const model of provider.models) {
|
|
31
|
+
const key = options.ignorePrefix ? stripModelIdPrefix(model.id) : model.id;
|
|
32
|
+
const prev = chosen.get(key);
|
|
33
|
+
if (prev === void 0 || prev !== key && model.id === key) chosen.set(key, model.id);
|
|
34
|
+
}
|
|
35
|
+
for (const model of provider.models) {
|
|
36
|
+
const key = options.ignorePrefix ? stripModelIdPrefix(model.id) : model.id;
|
|
37
|
+
if (chosen.get(key) !== model.id) continue;
|
|
38
|
+
let merged = byId.get(key);
|
|
39
|
+
if (merged === void 0) {
|
|
40
|
+
merged = {
|
|
41
|
+
id: key,
|
|
42
|
+
providers: []
|
|
43
|
+
};
|
|
44
|
+
byId.set(key, merged);
|
|
45
|
+
}
|
|
46
|
+
merged.providers.push({
|
|
47
|
+
provider: provider.id,
|
|
15
48
|
id: model.id,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
49
|
+
name: model.name,
|
|
50
|
+
contextWindow: model.contextWindow,
|
|
51
|
+
maxTokens: model.maxTokens
|
|
52
|
+
});
|
|
19
53
|
}
|
|
20
|
-
merged.providers.push({
|
|
21
|
-
provider: provider.id,
|
|
22
|
-
id: model.id,
|
|
23
|
-
name: model.name,
|
|
24
|
-
contextWindow: model.contextWindow,
|
|
25
|
-
maxTokens: model.maxTokens
|
|
26
|
-
});
|
|
27
54
|
}
|
|
28
55
|
return [...byId.values()];
|
|
29
56
|
}
|
|
@@ -109,6 +136,76 @@ function syncConfig(config, modelId, catalogProviderIds) {
|
|
|
109
136
|
changed
|
|
110
137
|
};
|
|
111
138
|
}
|
|
139
|
+
function sameModels(a, b) {
|
|
140
|
+
const aKeys = Object.keys(a);
|
|
141
|
+
const bKeys = Object.keys(b);
|
|
142
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
143
|
+
for (const key of aKeys) {
|
|
144
|
+
const x = a[key];
|
|
145
|
+
const y = b[key];
|
|
146
|
+
if (y === void 0 || x.active !== y.active || x.order.length !== y.order.length) return false;
|
|
147
|
+
if (!x.order.every((id, i) => id === y.order[i])) return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Reconcile the stored config document against the live merged catalog.
|
|
153
|
+
*
|
|
154
|
+
* Beyond the per-model sync of {@link syncConfig}, this remaps stored entries
|
|
155
|
+
* to their merged ids: with prefix-ignore on, an entry keyed by a raw id that
|
|
156
|
+
* now strips to a merged id (e.g. `deepseek/deepseek-v4-flash` →
|
|
157
|
+
* `deepseek-v4-flash`) migrates to that merged id. When several stored keys
|
|
158
|
+
* collapse onto one merged id, the entry keyed by the merged id itself wins
|
|
159
|
+
* for `active`, and `order` becomes the deduped union in exact-key-first
|
|
160
|
+
* order. Stored keys no longer mapping to any merged model are dropped (this
|
|
161
|
+
* also covers turning the flag off — split models re-initialize fresh).
|
|
162
|
+
*/
|
|
163
|
+
function reconcileConfig(config, models) {
|
|
164
|
+
const keyToMerged = /* @__PURE__ */ new Map();
|
|
165
|
+
for (const model of models) {
|
|
166
|
+
keyToMerged.set(model.id, model.id);
|
|
167
|
+
if (!config.ignoreModelIdPrefix) continue;
|
|
168
|
+
for (const provider of model.providers) if (stripModelIdPrefix(provider.id) === model.id && !keyToMerged.has(provider.id)) keyToMerged.set(provider.id, model.id);
|
|
169
|
+
}
|
|
170
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
171
|
+
for (const [key, entry] of Object.entries(config.models)) {
|
|
172
|
+
const mergedId = keyToMerged.get(key);
|
|
173
|
+
if (mergedId === void 0) continue;
|
|
174
|
+
const list = grouped.get(mergedId) ?? [];
|
|
175
|
+
list.push({
|
|
176
|
+
key,
|
|
177
|
+
entry
|
|
178
|
+
});
|
|
179
|
+
grouped.set(mergedId, list);
|
|
180
|
+
}
|
|
181
|
+
const next = {};
|
|
182
|
+
for (const model of models) {
|
|
183
|
+
const ids = model.providers.map((provider) => provider.provider);
|
|
184
|
+
const list = grouped.get(model.id);
|
|
185
|
+
let base;
|
|
186
|
+
if (list !== void 0 && list.length > 0) {
|
|
187
|
+
const exact = list.find((group) => group.key === model.id);
|
|
188
|
+
const ordered = exact === void 0 ? list : [exact, ...list.filter((group) => group.key !== model.id)];
|
|
189
|
+
const order = [];
|
|
190
|
+
for (const group of ordered) for (const id of group.entry.order) if (!order.includes(id)) order.push(id);
|
|
191
|
+
base = {
|
|
192
|
+
order,
|
|
193
|
+
active: exact?.entry.active ?? ordered[0].entry.active
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const view = {
|
|
197
|
+
...config,
|
|
198
|
+
models: { ...next }
|
|
199
|
+
};
|
|
200
|
+
if (base !== void 0) view.models[model.id] = base;
|
|
201
|
+
const entry = syncConfig(view, model.id, ids).models[model.id];
|
|
202
|
+
if (entry !== void 0) next[model.id] = entry;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
models: next,
|
|
206
|
+
changed: !sameModels(next, config.models)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
112
209
|
/** Switch a model's active provider (no-op when already active). */
|
|
113
210
|
function setActive(config, modelId, providerId, catalogProviderIds) {
|
|
114
211
|
if (!catalogProviderIds.includes(providerId)) throw new Error(`provider ${providerId} is not in the catalog for model ${modelId}`);
|
|
@@ -160,9 +257,20 @@ function setOrder(config, modelId, order, catalogProviderIds) {
|
|
|
160
257
|
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
161
258
|
function normalizeConfig(value) {
|
|
162
259
|
const models = {};
|
|
163
|
-
if (typeof value !== "object" || value === null) return {
|
|
164
|
-
|
|
165
|
-
|
|
260
|
+
if (typeof value !== "object" || value === null) return {
|
|
261
|
+
models,
|
|
262
|
+
showQuickSwitch: true,
|
|
263
|
+
ignoreModelIdPrefix: true
|
|
264
|
+
};
|
|
265
|
+
const root = value;
|
|
266
|
+
const showQuickSwitch = root.showQuickSwitch !== false;
|
|
267
|
+
const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
|
|
268
|
+
const raw = root.models;
|
|
269
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
270
|
+
models,
|
|
271
|
+
showQuickSwitch,
|
|
272
|
+
ignoreModelIdPrefix
|
|
273
|
+
};
|
|
166
274
|
for (const [id, entry] of Object.entries(raw)) {
|
|
167
275
|
if (typeof entry !== "object" || entry === null) continue;
|
|
168
276
|
const e = entry;
|
|
@@ -174,7 +282,11 @@ function normalizeConfig(value) {
|
|
|
174
282
|
active
|
|
175
283
|
};
|
|
176
284
|
}
|
|
177
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
models,
|
|
287
|
+
showQuickSwitch,
|
|
288
|
+
ignoreModelIdPrefix
|
|
289
|
+
};
|
|
178
290
|
}
|
|
179
291
|
//#endregion
|
|
180
292
|
//#region src/server/catalog.ts
|
|
@@ -220,7 +332,7 @@ async function detectCredentialConfigured(ctx, entry) {
|
|
|
220
332
|
}
|
|
221
333
|
}
|
|
222
334
|
/** Rebuild the live router catalog from the LLM registry. */
|
|
223
|
-
async function buildCatalog(ctx) {
|
|
335
|
+
async function buildCatalog(ctx, ignorePrefix = false) {
|
|
224
336
|
const routes = ctx.llm.listProviders().filter((provider) => provider.id !== ROUTER_PROVIDER_ID);
|
|
225
337
|
const directory = new Map(ctx.llm.listConfigurableProviders().map((entry) => [entry.provider, {
|
|
226
338
|
provider: entry.provider,
|
|
@@ -253,7 +365,7 @@ async function buildCatalog(ctx) {
|
|
|
253
365
|
});
|
|
254
366
|
credentialConfigured.set(route.id, await detectCredentialConfigured(ctx, entry));
|
|
255
367
|
}
|
|
256
|
-
const models = mergeModels(providers);
|
|
368
|
+
const models = mergeModels(providers, { ignorePrefix });
|
|
257
369
|
return {
|
|
258
370
|
providers,
|
|
259
371
|
providerNames,
|
|
@@ -269,20 +381,42 @@ async function buildCatalog(ctx) {
|
|
|
269
381
|
* route `model-router`, it advertises the merged catalog and, at request time,
|
|
270
382
|
* rewrites the provider field to the model's currently active provider and
|
|
271
383
|
* delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
|
|
272
|
-
* and metering all stay inside the real adapter's pipeline.
|
|
384
|
+
* and metering all stay inside the real adapter's pipeline. Incoming model
|
|
385
|
+
* ids are matched with the leading vendor prefix ignored when the settings
|
|
386
|
+
* flag is on, and delegation always uses the active provider's raw local id.
|
|
273
387
|
*/
|
|
274
388
|
/**
|
|
389
|
+
* Find the merged model for an incoming model id. Exact lookup first; when
|
|
390
|
+
* prefix-ignore is on, a miss falls back to the prefix-stripped id so a raw
|
|
391
|
+
* `deepseek/deepseek-v4-flash` request matches the merged `deepseek-v4-flash`
|
|
392
|
+
* (advertised ids are already stripped and hit exactly). Unadvertised ids do
|
|
393
|
+
* not match.
|
|
394
|
+
*/
|
|
395
|
+
function mergedFor(facts, modelId) {
|
|
396
|
+
const byId = facts.catalog().byId;
|
|
397
|
+
const direct = byId.get(modelId);
|
|
398
|
+
if (direct !== void 0) return direct;
|
|
399
|
+
if (!facts.config().ignoreModelIdPrefix) return null;
|
|
400
|
+
const stripped = stripModelIdPrefix(modelId);
|
|
401
|
+
if (stripped === modelId) return null;
|
|
402
|
+
return byId.get(stripped) ?? null;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
275
405
|
* Resolve the effective route for one model id: the stored active provider
|
|
276
406
|
* when still present, otherwise the first remaining order entry, otherwise
|
|
277
407
|
* the catalog head. Unadvertised model ids do not route.
|
|
278
408
|
*/
|
|
279
409
|
function routeFor(facts, modelId) {
|
|
280
|
-
const merged = facts
|
|
281
|
-
if (merged ===
|
|
410
|
+
const merged = mergedFor(facts, modelId);
|
|
411
|
+
if (merged === null) return null;
|
|
282
412
|
const ids = merged.providers.map((provider) => provider.provider);
|
|
283
|
-
const active = resolveActive(facts.config(),
|
|
413
|
+
const active = resolveActive(facts.config(), merged.id, ids);
|
|
284
414
|
return active === null ? null : { provider: active };
|
|
285
415
|
}
|
|
416
|
+
/** The raw provider-local id of one provider entry inside a merged model. */
|
|
417
|
+
function entryRawId(merged, providerId) {
|
|
418
|
+
return merged.providers.find((provider) => provider.provider === providerId)?.id ?? merged.id;
|
|
419
|
+
}
|
|
286
420
|
var ModelRouterAdapter = class extends LlmAdapter {
|
|
287
421
|
facts;
|
|
288
422
|
constructor(facts) {
|
|
@@ -313,18 +447,25 @@ var ModelRouterAdapter = class extends LlmAdapter {
|
|
|
313
447
|
}
|
|
314
448
|
async resolveModel(_provider, model, signal) {
|
|
315
449
|
const route = routeFor(this.facts, model);
|
|
316
|
-
|
|
450
|
+
const merged = route === null ? null : mergedFor(this.facts, model);
|
|
451
|
+
if (route === null || merged === null) throw new LlmError(`model-router: no provider serves model "${model}"`, "NO_ADAPTER");
|
|
452
|
+
const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal);
|
|
453
|
+
const display = displayProvider(merged, route.provider);
|
|
317
454
|
return {
|
|
318
|
-
...
|
|
319
|
-
provider: ROUTER_PROVIDER_ID
|
|
455
|
+
...resolved,
|
|
456
|
+
provider: ROUTER_PROVIDER_ID,
|
|
457
|
+
id: merged.id,
|
|
458
|
+
name: display?.name ?? resolved.name
|
|
320
459
|
};
|
|
321
460
|
}
|
|
322
461
|
async *stream(options) {
|
|
323
462
|
const route = routeFor(this.facts, options.model);
|
|
324
|
-
|
|
463
|
+
const merged = route === null ? null : mergedFor(this.facts, options.model);
|
|
464
|
+
if (route === null || merged === null) throw new LlmError(`model-router: no provider serves model "${options.model}"`, "NO_ADAPTER");
|
|
325
465
|
yield* this.facts.llm().stream({
|
|
326
466
|
...options,
|
|
327
|
-
provider: route.provider
|
|
467
|
+
provider: route.provider,
|
|
468
|
+
model: entryRawId(merged, route.provider)
|
|
328
469
|
});
|
|
329
470
|
}
|
|
330
471
|
};
|
|
@@ -332,11 +473,19 @@ var ModelRouterAdapter = class extends LlmAdapter {
|
|
|
332
473
|
//#region src/server/service.ts
|
|
333
474
|
const NS = settingsNamespace(ROUTER_SETTINGS_NS);
|
|
334
475
|
/** Settings document schema: one entry per model id. */
|
|
335
|
-
const RouterConfigSchema = z.object({
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
476
|
+
const RouterConfigSchema = z.object({
|
|
477
|
+
models: z.dict(z.object({
|
|
478
|
+
order: z.array(z.string()),
|
|
479
|
+
active: z.string()
|
|
480
|
+
})),
|
|
481
|
+
showQuickSwitch: z.boolean(),
|
|
482
|
+
ignoreModelIdPrefix: z.boolean()
|
|
483
|
+
});
|
|
484
|
+
const EMPTY_CONFIG = {
|
|
485
|
+
models: {},
|
|
486
|
+
showQuickSwitch: true,
|
|
487
|
+
ignoreModelIdPrefix: true
|
|
488
|
+
};
|
|
340
489
|
var ModelRouterService = class {
|
|
341
490
|
ctx;
|
|
342
491
|
cached = {
|
|
@@ -391,7 +540,7 @@ var ModelRouterService = class {
|
|
|
391
540
|
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
392
541
|
refreshCatalog() {
|
|
393
542
|
if (this.inflight !== null) return this.inflight;
|
|
394
|
-
this.inflight = buildCatalog(this.ctx).then((catalog) => {
|
|
543
|
+
this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix).then((catalog) => {
|
|
395
544
|
this.cached = catalog;
|
|
396
545
|
return catalog;
|
|
397
546
|
}).catch((error) => {
|
|
@@ -402,27 +551,19 @@ var ModelRouterService = class {
|
|
|
402
551
|
});
|
|
403
552
|
return this.inflight;
|
|
404
553
|
}
|
|
405
|
-
/** Refresh, then persist any config drift (new
|
|
554
|
+
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
406
555
|
async refreshAndReconcile() {
|
|
407
556
|
const catalog = await this.refreshCatalog();
|
|
408
557
|
if (this.scope === void 0) return;
|
|
409
558
|
const current = this.config();
|
|
410
|
-
const
|
|
411
|
-
let changed = false;
|
|
412
|
-
for (const model of catalog.models) {
|
|
413
|
-
const ids = model.providers.map((provider) => provider.provider);
|
|
414
|
-
const result = syncConfig(current, model.id, ids);
|
|
415
|
-
if (result.changed) changed = true;
|
|
416
|
-
for (const [id, entry] of Object.entries(result.models)) if (entry === void 0) delete next[id];
|
|
417
|
-
else next[id] = entry;
|
|
418
|
-
}
|
|
419
|
-
for (const id of Object.keys(next)) if (!catalog.byId.has(id)) {
|
|
420
|
-
delete next[id];
|
|
421
|
-
changed = true;
|
|
422
|
-
}
|
|
559
|
+
const { models, changed } = reconcileConfig(current, catalog.models);
|
|
423
560
|
if (!changed) return;
|
|
424
561
|
try {
|
|
425
|
-
await this.scope.replace({
|
|
562
|
+
await this.scope.replace({
|
|
563
|
+
models,
|
|
564
|
+
showQuickSwitch: current.showQuickSwitch,
|
|
565
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
566
|
+
});
|
|
426
567
|
} catch (error) {
|
|
427
568
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
428
569
|
}
|
|
@@ -440,6 +581,8 @@ var ModelRouterService = class {
|
|
|
440
581
|
const names = catalog.providerNames;
|
|
441
582
|
const creds = catalog.credentialConfigured;
|
|
442
583
|
return {
|
|
584
|
+
showQuickSwitch: config.showQuickSwitch,
|
|
585
|
+
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
443
586
|
providers: catalog.providers.map((provider) => ({
|
|
444
587
|
id: provider.id,
|
|
445
588
|
name: names.get(provider.id) ?? provider.id,
|
|
@@ -482,9 +625,42 @@ var ModelRouterService = class {
|
|
|
482
625
|
if (!changed) return;
|
|
483
626
|
await this.persist(models);
|
|
484
627
|
}
|
|
628
|
+
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
629
|
+
async setShowQuickSwitch(value) {
|
|
630
|
+
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
631
|
+
const current = this.config();
|
|
632
|
+
if (current.showQuickSwitch === value) return;
|
|
633
|
+
await this.scope.replace({
|
|
634
|
+
models: current.models,
|
|
635
|
+
showQuickSwitch: value,
|
|
636
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Toggle prefix-ignored model matching (global, persisted). Persists the
|
|
641
|
+
* flag first, then rebuilds the catalog with the new merge rule and
|
|
642
|
+
* reconciles stored config (migrates prefixed keys to merged ids when
|
|
643
|
+
* turning on; split models re-initialize when turning off).
|
|
644
|
+
*/
|
|
645
|
+
async setIgnoreModelIdPrefix(value) {
|
|
646
|
+
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
647
|
+
const current = this.config();
|
|
648
|
+
if (current.ignoreModelIdPrefix === value) return;
|
|
649
|
+
await this.scope.replace({
|
|
650
|
+
models: current.models,
|
|
651
|
+
showQuickSwitch: current.showQuickSwitch,
|
|
652
|
+
ignoreModelIdPrefix: value
|
|
653
|
+
});
|
|
654
|
+
await this.refreshAndReconcile();
|
|
655
|
+
}
|
|
485
656
|
async persist(models) {
|
|
486
657
|
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
487
|
-
|
|
658
|
+
const current = this.config();
|
|
659
|
+
await this.scope.replace({
|
|
660
|
+
models,
|
|
661
|
+
showQuickSwitch: current.showQuickSwitch,
|
|
662
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
663
|
+
});
|
|
488
664
|
}
|
|
489
665
|
};
|
|
490
666
|
//#endregion
|
|
@@ -599,6 +775,16 @@ function registerApiRoutes(ctx, service) {
|
|
|
599
775
|
await service.refreshAndReconcile();
|
|
600
776
|
return service.state();
|
|
601
777
|
});
|
|
778
|
+
route("set-show-quick-switch", async (p) => {
|
|
779
|
+
if (typeof p.value !== "boolean") throw new Error("value 必须是布尔值");
|
|
780
|
+
await service.setShowQuickSwitch(p.value);
|
|
781
|
+
return service.state();
|
|
782
|
+
});
|
|
783
|
+
route("set-ignore-model-id-prefix", async (p) => {
|
|
784
|
+
if (typeof p.value !== "boolean") throw new Error("value 必须是布尔值");
|
|
785
|
+
await service.setIgnoreModelIdPrefix(p.value);
|
|
786
|
+
return service.state();
|
|
787
|
+
});
|
|
602
788
|
return () => {
|
|
603
789
|
for (const dispose of disposers) dispose();
|
|
604
790
|
};
|
|
@@ -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, type LlmRuntime } from '@deepseek-ai/dsh-llm';
|
|
9
11
|
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
@@ -26,12 +28,22 @@ export interface RouterFacts {
|
|
|
26
28
|
export interface ResolvedRoute {
|
|
27
29
|
provider: string;
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Find the merged model for an incoming model id. Exact lookup first; when
|
|
33
|
+
* prefix-ignore is on, a miss falls back to the prefix-stripped id so a raw
|
|
34
|
+
* `deepseek/deepseek-v4-flash` request matches the merged `deepseek-v4-flash`
|
|
35
|
+
* (advertised ids are already stripped and hit exactly). Unadvertised ids do
|
|
36
|
+
* not match.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mergedFor(facts: RouterFacts, modelId: string): MergedModel | null;
|
|
29
39
|
/**
|
|
30
40
|
* Resolve the effective route for one model id: the stored active provider
|
|
31
41
|
* when still present, otherwise the first remaining order entry, otherwise
|
|
32
42
|
* the catalog head. Unadvertised model ids do not route.
|
|
33
43
|
*/
|
|
34
44
|
export declare function routeFor(facts: RouterFacts, modelId: string): ResolvedRoute | null;
|
|
45
|
+
/** The raw provider-local id of one provider entry inside a merged model. */
|
|
46
|
+
export declare function entryRawId(merged: MergedModel, providerId: string): string;
|
|
35
47
|
export declare class ModelRouterAdapter extends LlmAdapter {
|
|
36
48
|
private readonly facts;
|
|
37
49
|
constructor(facts: RouterFacts);
|
|
@@ -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 { type MergedModel, type ProviderInfo } from '../shared/config.js';
|
|
@@ -13,7 +13,7 @@ export interface RouterCatalog {
|
|
|
13
13
|
providers: ProviderInfo[];
|
|
14
14
|
/** Provider id → human display name (configurable-directory name when known). */
|
|
15
15
|
providerNames: Map<string, string>;
|
|
16
|
-
/** Models merged
|
|
16
|
+
/** Models merged across providers (exact id, or stripped when flag on). */
|
|
17
17
|
models: MergedModel[];
|
|
18
18
|
/** Model id → merged model. */
|
|
19
19
|
byId: Map<string, MergedModel>;
|
|
@@ -21,4 +21,4 @@ export interface RouterCatalog {
|
|
|
21
21
|
credentialConfigured: Map<string, boolean>;
|
|
22
22
|
}
|
|
23
23
|
/** Rebuild the live router catalog from the LLM registry. */
|
|
24
|
-
export declare function buildCatalog(ctx: Context): Promise<RouterCatalog>;
|
|
24
|
+
export declare function buildCatalog(ctx: Context, ignorePrefix?: boolean): Promise<RouterCatalog>;
|
|
@@ -13,6 +13,10 @@ export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
|
|
|
13
13
|
export declare const RouterConfigSchema: z<RouterConfigShape>;
|
|
14
14
|
/** The settings-page state view served by the API. */
|
|
15
15
|
export interface ModelRouterState {
|
|
16
|
+
/** Whether the composer quick route-switcher button is enabled. */
|
|
17
|
+
showQuickSwitch: boolean;
|
|
18
|
+
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
19
|
+
ignoreModelIdPrefix: boolean;
|
|
16
20
|
providers: Array<{
|
|
17
21
|
id: string;
|
|
18
22
|
name: string;
|
|
@@ -52,7 +56,7 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
52
56
|
llm(): import("@deepseek-ai/dsh-llm").LlmRuntime;
|
|
53
57
|
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
54
58
|
refreshCatalog(): Promise<RouterCatalog>;
|
|
55
|
-
/** Refresh, then persist any config drift (new
|
|
59
|
+
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
56
60
|
refreshAndReconcile(): Promise<void>;
|
|
57
61
|
/** Effective active provider for one model id (null when unroutable). */
|
|
58
62
|
activeFor(modelId: string): string | null;
|
|
@@ -62,5 +66,14 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
62
66
|
switchActive(modelId: string, providerId: string): Promise<void>;
|
|
63
67
|
/** Replace the preference order for one model (active left untouched). */
|
|
64
68
|
reorder(modelId: string, order: string[]): Promise<void>;
|
|
69
|
+
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
70
|
+
setShowQuickSwitch(value: boolean): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Toggle prefix-ignored model matching (global, persisted). Persists the
|
|
73
|
+
* flag first, then rebuilds the catalog with the new merge rule and
|
|
74
|
+
* reconciles stored config (migrates prefixed keys to merged ids when
|
|
75
|
+
* turning on; split models re-initialize when turning off).
|
|
76
|
+
*/
|
|
77
|
+
setIgnoreModelIdPrefix(value: boolean): Promise<void>;
|
|
65
78
|
private persist;
|
|
66
79
|
}
|
|
@@ -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,12 +18,17 @@
|
|
|
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
|
/** One provider's view of one model. */
|
|
19
27
|
export interface ProviderModelInfo {
|
|
20
28
|
/** Provider id the model belongs to. */
|
|
21
29
|
provider: string;
|
|
22
|
-
/** Provider-local model id
|
|
30
|
+
/** Provider-local raw model id, exactly as the provider advertises it
|
|
31
|
+
* (may carry a `vendor/` prefix; this is the id delegation must use). */
|
|
23
32
|
id: string;
|
|
24
33
|
/** Provider-local display name. */
|
|
25
34
|
name: string;
|
|
@@ -35,13 +44,15 @@ export interface ProviderInfo {
|
|
|
35
44
|
name?: string;
|
|
36
45
|
models: Array<Omit<ProviderModelInfo, 'provider'>>;
|
|
37
46
|
}
|
|
38
|
-
/** One merged model across all providers
|
|
47
|
+
/** One merged model across all providers. */
|
|
39
48
|
export interface MergedModel {
|
|
49
|
+
/** Merged display id — the raw id with the leading segment stripped when
|
|
50
|
+
* prefix-ignore is on, so it is always shown without a vendor prefix. */
|
|
40
51
|
id: string;
|
|
41
|
-
/** Per-provider entries in provider discovery order. */
|
|
52
|
+
/** Per-provider entries in provider discovery order (raw local ids). */
|
|
42
53
|
providers: ProviderModelInfo[];
|
|
43
54
|
}
|
|
44
|
-
/** Router configuration for one model. */
|
|
55
|
+
/** Router configuration for one model (keyed by merged id). */
|
|
45
56
|
export interface RouterModelConfig {
|
|
46
57
|
/** Provider ids in preference order (future auto-failover). */
|
|
47
58
|
order: string[];
|
|
@@ -51,11 +62,42 @@ export interface RouterModelConfig {
|
|
|
51
62
|
/** The whole router config document. */
|
|
52
63
|
export interface RouterConfigShape {
|
|
53
64
|
models: Record<string, RouterModelConfig>;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the quick route-switcher button is shown beside the composer
|
|
67
|
+
* model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
|
|
68
|
+
* globally; absent/unknown documents normalize to `true`.
|
|
69
|
+
*/
|
|
70
|
+
showQuickSwitch: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Whether model ids are matched with their leading `vendor/` segment
|
|
73
|
+
* ignored (settings → 模型路由 → 匹配时忽略模型 ID 前缀), so
|
|
74
|
+
* `deepseek/deepseek-v4-flash` and `deepseek-v4-flash` merge into one model
|
|
75
|
+
* displayed as `deepseek-v4-flash`. Absent/unknown documents normalize to
|
|
76
|
+
* `true`.
|
|
77
|
+
*/
|
|
78
|
+
ignoreModelIdPrefix: boolean;
|
|
54
79
|
}
|
|
55
80
|
export declare const ROUTER_PROVIDER_ID = "model-router";
|
|
56
81
|
export declare const ROUTER_SETTINGS_NS = "model-router";
|
|
57
|
-
/**
|
|
58
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Strip the first path segment (`vendor/`) from a model id, case-sensitively.
|
|
84
|
+
* Inert for ids without a prefix: no slash, an empty first segment, or an
|
|
85
|
+
* empty remainder all return the input unchanged. `a/b/c` becomes `b/c`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stripModelIdPrefix(id: string): string;
|
|
88
|
+
export interface MergeOptions {
|
|
89
|
+
/** Group by prefix-stripped id instead of the exact raw id. */
|
|
90
|
+
ignorePrefix?: boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Merge every provider's models into one entry per model id. With
|
|
94
|
+
* `ignorePrefix` the group key is the prefix-stripped id and the merged id is
|
|
95
|
+
* that stripped id, while each provider entry keeps its raw local id. When a
|
|
96
|
+
* single provider advertises both a prefixed and the unprefixed raw id for the
|
|
97
|
+
* same group (a same-provider collision), the unprefixed raw id wins (it is
|
|
98
|
+
* the canonical form); otherwise the first-seen raw id wins.
|
|
99
|
+
*/
|
|
100
|
+
export declare function mergeModels(providers: readonly ProviderInfo[], options?: MergeOptions): MergedModel[];
|
|
59
101
|
/** The provider view a merged model should display for (follows active). */
|
|
60
102
|
export declare function displayProvider(merged: MergedModel, active: string | undefined): ProviderModelInfo | null;
|
|
61
103
|
/** Initial config for a model the user never configured: first in order wins. */
|
|
@@ -78,6 +120,22 @@ export declare function syncConfig(config: RouterConfigShape, modelId: string, c
|
|
|
78
120
|
models: Record<string, RouterModelConfig>;
|
|
79
121
|
changed: boolean;
|
|
80
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Reconcile the stored config document against the live merged catalog.
|
|
125
|
+
*
|
|
126
|
+
* Beyond the per-model sync of {@link syncConfig}, this remaps stored entries
|
|
127
|
+
* to their merged ids: with prefix-ignore on, an entry keyed by a raw id that
|
|
128
|
+
* now strips to a merged id (e.g. `deepseek/deepseek-v4-flash` →
|
|
129
|
+
* `deepseek-v4-flash`) migrates to that merged id. When several stored keys
|
|
130
|
+
* collapse onto one merged id, the entry keyed by the merged id itself wins
|
|
131
|
+
* for `active`, and `order` becomes the deduped union in exact-key-first
|
|
132
|
+
* order. Stored keys no longer mapping to any merged model are dropped (this
|
|
133
|
+
* also covers turning the flag off — split models re-initialize fresh).
|
|
134
|
+
*/
|
|
135
|
+
export declare function reconcileConfig(config: RouterConfigShape, models: readonly MergedModel[]): {
|
|
136
|
+
models: Record<string, RouterModelConfig>;
|
|
137
|
+
changed: boolean;
|
|
138
|
+
};
|
|
81
139
|
/** Switch a model's active provider (no-op when already active). */
|
|
82
140
|
export declare function setActive(config: RouterConfigShape, modelId: string, providerId: string, catalogProviderIds: readonly string[]): {
|
|
83
141
|
models: Record<string, RouterModelConfig>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-model-router",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "DSH plugin: a virtual model-router provider that aggregates every configured provider and model, routes each request to the model's currently active provider, and ships a settings page for per-model provider ordering and switching.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -61,7 +61,9 @@
|
|
|
61
61
|
"@deepseek-ai/dsh-client-locale",
|
|
62
62
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
63
63
|
"@deepseek-ai/dsh-client-ui-primitives",
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-model-selection"
|
|
65
67
|
]
|
|
66
68
|
}
|
|
67
69
|
},
|
|
@@ -75,6 +77,8 @@
|
|
|
75
77
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
76
78
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
77
79
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
|
|
78
82
|
"react": "^18.2.0",
|
|
79
83
|
"react-dom": "^18.2.0"
|
|
80
84
|
},
|
|
@@ -93,6 +97,8 @@
|
|
|
93
97
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
94
98
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
95
99
|
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
100
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
|
101
|
+
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
|
|
96
102
|
"vitest": "^3.2.0"
|
|
97
103
|
},
|
|
98
104
|
"packageManager": "pnpm@11.21.0"
|