@fonlan/dsh-model-router 0.2.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 +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 +1 -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
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}`);
|
|
@@ -162,14 +259,17 @@ function normalizeConfig(value) {
|
|
|
162
259
|
const models = {};
|
|
163
260
|
if (typeof value !== "object" || value === null) return {
|
|
164
261
|
models,
|
|
165
|
-
showQuickSwitch: true
|
|
262
|
+
showQuickSwitch: true,
|
|
263
|
+
ignoreModelIdPrefix: true
|
|
166
264
|
};
|
|
167
265
|
const root = value;
|
|
168
266
|
const showQuickSwitch = root.showQuickSwitch !== false;
|
|
267
|
+
const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
|
|
169
268
|
const raw = root.models;
|
|
170
269
|
if (typeof raw !== "object" || raw === null) return {
|
|
171
270
|
models,
|
|
172
|
-
showQuickSwitch
|
|
271
|
+
showQuickSwitch,
|
|
272
|
+
ignoreModelIdPrefix
|
|
173
273
|
};
|
|
174
274
|
for (const [id, entry] of Object.entries(raw)) {
|
|
175
275
|
if (typeof entry !== "object" || entry === null) continue;
|
|
@@ -184,7 +284,8 @@ function normalizeConfig(value) {
|
|
|
184
284
|
}
|
|
185
285
|
return {
|
|
186
286
|
models,
|
|
187
|
-
showQuickSwitch
|
|
287
|
+
showQuickSwitch,
|
|
288
|
+
ignoreModelIdPrefix
|
|
188
289
|
};
|
|
189
290
|
}
|
|
190
291
|
//#endregion
|
|
@@ -231,7 +332,7 @@ async function detectCredentialConfigured(ctx, entry) {
|
|
|
231
332
|
}
|
|
232
333
|
}
|
|
233
334
|
/** Rebuild the live router catalog from the LLM registry. */
|
|
234
|
-
async function buildCatalog(ctx) {
|
|
335
|
+
async function buildCatalog(ctx, ignorePrefix = false) {
|
|
235
336
|
const routes = ctx.llm.listProviders().filter((provider) => provider.id !== ROUTER_PROVIDER_ID);
|
|
236
337
|
const directory = new Map(ctx.llm.listConfigurableProviders().map((entry) => [entry.provider, {
|
|
237
338
|
provider: entry.provider,
|
|
@@ -264,7 +365,7 @@ async function buildCatalog(ctx) {
|
|
|
264
365
|
});
|
|
265
366
|
credentialConfigured.set(route.id, await detectCredentialConfigured(ctx, entry));
|
|
266
367
|
}
|
|
267
|
-
const models = mergeModels(providers);
|
|
368
|
+
const models = mergeModels(providers, { ignorePrefix });
|
|
268
369
|
return {
|
|
269
370
|
providers,
|
|
270
371
|
providerNames,
|
|
@@ -280,20 +381,42 @@ async function buildCatalog(ctx) {
|
|
|
280
381
|
* route `model-router`, it advertises the merged catalog and, at request time,
|
|
281
382
|
* rewrites the provider field to the model's currently active provider and
|
|
282
383
|
* delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
|
|
283
|
-
* 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.
|
|
387
|
+
*/
|
|
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.
|
|
284
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
|
+
}
|
|
285
404
|
/**
|
|
286
405
|
* Resolve the effective route for one model id: the stored active provider
|
|
287
406
|
* when still present, otherwise the first remaining order entry, otherwise
|
|
288
407
|
* the catalog head. Unadvertised model ids do not route.
|
|
289
408
|
*/
|
|
290
409
|
function routeFor(facts, modelId) {
|
|
291
|
-
const merged = facts
|
|
292
|
-
if (merged ===
|
|
410
|
+
const merged = mergedFor(facts, modelId);
|
|
411
|
+
if (merged === null) return null;
|
|
293
412
|
const ids = merged.providers.map((provider) => provider.provider);
|
|
294
|
-
const active = resolveActive(facts.config(),
|
|
413
|
+
const active = resolveActive(facts.config(), merged.id, ids);
|
|
295
414
|
return active === null ? null : { provider: active };
|
|
296
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
|
+
}
|
|
297
420
|
var ModelRouterAdapter = class extends LlmAdapter {
|
|
298
421
|
facts;
|
|
299
422
|
constructor(facts) {
|
|
@@ -324,18 +447,25 @@ var ModelRouterAdapter = class extends LlmAdapter {
|
|
|
324
447
|
}
|
|
325
448
|
async resolveModel(_provider, model, signal) {
|
|
326
449
|
const route = routeFor(this.facts, model);
|
|
327
|
-
|
|
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);
|
|
328
454
|
return {
|
|
329
|
-
...
|
|
330
|
-
provider: ROUTER_PROVIDER_ID
|
|
455
|
+
...resolved,
|
|
456
|
+
provider: ROUTER_PROVIDER_ID,
|
|
457
|
+
id: merged.id,
|
|
458
|
+
name: display?.name ?? resolved.name
|
|
331
459
|
};
|
|
332
460
|
}
|
|
333
461
|
async *stream(options) {
|
|
334
462
|
const route = routeFor(this.facts, options.model);
|
|
335
|
-
|
|
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");
|
|
336
465
|
yield* this.facts.llm().stream({
|
|
337
466
|
...options,
|
|
338
|
-
provider: route.provider
|
|
467
|
+
provider: route.provider,
|
|
468
|
+
model: entryRawId(merged, route.provider)
|
|
339
469
|
});
|
|
340
470
|
}
|
|
341
471
|
};
|
|
@@ -348,11 +478,13 @@ const RouterConfigSchema = z.object({
|
|
|
348
478
|
order: z.array(z.string()),
|
|
349
479
|
active: z.string()
|
|
350
480
|
})),
|
|
351
|
-
showQuickSwitch: z.boolean()
|
|
481
|
+
showQuickSwitch: z.boolean(),
|
|
482
|
+
ignoreModelIdPrefix: z.boolean()
|
|
352
483
|
});
|
|
353
484
|
const EMPTY_CONFIG = {
|
|
354
485
|
models: {},
|
|
355
|
-
showQuickSwitch: true
|
|
486
|
+
showQuickSwitch: true,
|
|
487
|
+
ignoreModelIdPrefix: true
|
|
356
488
|
};
|
|
357
489
|
var ModelRouterService = class {
|
|
358
490
|
ctx;
|
|
@@ -408,7 +540,7 @@ var ModelRouterService = class {
|
|
|
408
540
|
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
409
541
|
refreshCatalog() {
|
|
410
542
|
if (this.inflight !== null) return this.inflight;
|
|
411
|
-
this.inflight = buildCatalog(this.ctx).then((catalog) => {
|
|
543
|
+
this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix).then((catalog) => {
|
|
412
544
|
this.cached = catalog;
|
|
413
545
|
return catalog;
|
|
414
546
|
}).catch((error) => {
|
|
@@ -419,29 +551,18 @@ var ModelRouterService = class {
|
|
|
419
551
|
});
|
|
420
552
|
return this.inflight;
|
|
421
553
|
}
|
|
422
|
-
/** Refresh, then persist any config drift (new
|
|
554
|
+
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
423
555
|
async refreshAndReconcile() {
|
|
424
556
|
const catalog = await this.refreshCatalog();
|
|
425
557
|
if (this.scope === void 0) return;
|
|
426
558
|
const current = this.config();
|
|
427
|
-
const
|
|
428
|
-
let changed = false;
|
|
429
|
-
for (const model of catalog.models) {
|
|
430
|
-
const ids = model.providers.map((provider) => provider.provider);
|
|
431
|
-
const result = syncConfig(current, model.id, ids);
|
|
432
|
-
if (result.changed) changed = true;
|
|
433
|
-
for (const [id, entry] of Object.entries(result.models)) if (entry === void 0) delete next[id];
|
|
434
|
-
else next[id] = entry;
|
|
435
|
-
}
|
|
436
|
-
for (const id of Object.keys(next)) if (!catalog.byId.has(id)) {
|
|
437
|
-
delete next[id];
|
|
438
|
-
changed = true;
|
|
439
|
-
}
|
|
559
|
+
const { models, changed } = reconcileConfig(current, catalog.models);
|
|
440
560
|
if (!changed) return;
|
|
441
561
|
try {
|
|
442
562
|
await this.scope.replace({
|
|
443
|
-
models
|
|
444
|
-
showQuickSwitch:
|
|
563
|
+
models,
|
|
564
|
+
showQuickSwitch: current.showQuickSwitch,
|
|
565
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
445
566
|
});
|
|
446
567
|
} catch (error) {
|
|
447
568
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -461,6 +582,7 @@ var ModelRouterService = class {
|
|
|
461
582
|
const creds = catalog.credentialConfigured;
|
|
462
583
|
return {
|
|
463
584
|
showQuickSwitch: config.showQuickSwitch,
|
|
585
|
+
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
464
586
|
providers: catalog.providers.map((provider) => ({
|
|
465
587
|
id: provider.id,
|
|
466
588
|
name: names.get(provider.id) ?? provider.id,
|
|
@@ -510,14 +632,34 @@ var ModelRouterService = class {
|
|
|
510
632
|
if (current.showQuickSwitch === value) return;
|
|
511
633
|
await this.scope.replace({
|
|
512
634
|
models: current.models,
|
|
513
|
-
showQuickSwitch: value
|
|
635
|
+
showQuickSwitch: value,
|
|
636
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
514
637
|
});
|
|
515
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
|
+
}
|
|
516
656
|
async persist(models) {
|
|
517
657
|
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
658
|
+
const current = this.config();
|
|
518
659
|
await this.scope.replace({
|
|
519
660
|
models,
|
|
520
|
-
showQuickSwitch:
|
|
661
|
+
showQuickSwitch: current.showQuickSwitch,
|
|
662
|
+
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
521
663
|
});
|
|
522
664
|
}
|
|
523
665
|
};
|
|
@@ -638,6 +780,11 @@ function registerApiRoutes(ctx, service) {
|
|
|
638
780
|
await service.setShowQuickSwitch(p.value);
|
|
639
781
|
return service.state();
|
|
640
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
|
+
});
|
|
641
788
|
return () => {
|
|
642
789
|
for (const dispose of disposers) dispose();
|
|
643
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>;
|
|
@@ -15,6 +15,8 @@ export declare const RouterConfigSchema: z<RouterConfigShape>;
|
|
|
15
15
|
export interface ModelRouterState {
|
|
16
16
|
/** Whether the composer quick route-switcher button is enabled. */
|
|
17
17
|
showQuickSwitch: boolean;
|
|
18
|
+
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
19
|
+
ignoreModelIdPrefix: boolean;
|
|
18
20
|
providers: Array<{
|
|
19
21
|
id: string;
|
|
20
22
|
name: string;
|
|
@@ -54,7 +56,7 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
54
56
|
llm(): import("@deepseek-ai/dsh-llm").LlmRuntime;
|
|
55
57
|
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
56
58
|
refreshCatalog(): Promise<RouterCatalog>;
|
|
57
|
-
/** Refresh, then persist any config drift (new
|
|
59
|
+
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
58
60
|
refreshAndReconcile(): Promise<void>;
|
|
59
61
|
/** Effective active provider for one model id (null when unroutable). */
|
|
60
62
|
activeFor(modelId: string): string | null;
|
|
@@ -66,5 +68,12 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
66
68
|
reorder(modelId: string, order: string[]): Promise<void>;
|
|
67
69
|
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
68
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>;
|
|
69
78
|
private persist;
|
|
70
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[];
|
|
@@ -57,11 +68,36 @@ export interface RouterConfigShape {
|
|
|
57
68
|
* globally; absent/unknown documents normalize to `true`.
|
|
58
69
|
*/
|
|
59
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;
|
|
60
79
|
}
|
|
61
80
|
export declare const ROUTER_PROVIDER_ID = "model-router";
|
|
62
81
|
export declare const ROUTER_SETTINGS_NS = "model-router";
|
|
63
|
-
/**
|
|
64
|
-
|
|
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[];
|
|
65
101
|
/** The provider view a merged model should display for (follows active). */
|
|
66
102
|
export declare function displayProvider(merged: MergedModel, active: string | undefined): ProviderModelInfo | null;
|
|
67
103
|
/** Initial config for a model the user never configured: first in order wins. */
|
|
@@ -84,6 +120,22 @@ export declare function syncConfig(config: RouterConfigShape, modelId: string, c
|
|
|
84
120
|
models: Record<string, RouterModelConfig>;
|
|
85
121
|
changed: boolean;
|
|
86
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
|
+
};
|
|
87
139
|
/** Switch a model's active provider (no-op when already active). */
|
|
88
140
|
export declare function setActive(config: RouterConfigShape, modelId: string, providerId: string, catalogProviderIds: readonly string[]): {
|
|
89
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.2.
|
|
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": {
|
package/src/client/api.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface RouterModelView {
|
|
|
16
16
|
export interface ModelRouterState {
|
|
17
17
|
/** Whether the composer quick route-switcher button is enabled. */
|
|
18
18
|
showQuickSwitch: boolean
|
|
19
|
+
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
20
|
+
ignoreModelIdPrefix: boolean
|
|
19
21
|
providers: RouterProviderView[]
|
|
20
22
|
models: RouterModelView[]
|
|
21
23
|
}
|
|
@@ -57,4 +59,6 @@ export const api = {
|
|
|
57
59
|
refresh: () => call<ModelRouterState>('refresh'),
|
|
58
60
|
setShowQuickSwitch: (value: boolean) =>
|
|
59
61
|
call<ModelRouterState>('set-show-quick-switch', { value }),
|
|
62
|
+
setIgnoreModelIdPrefix: (value: boolean) =>
|
|
63
|
+
call<ModelRouterState>('set-ignore-model-id-prefix', { value }),
|
|
60
64
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,8 @@ export const zh = {
|
|
|
29
29
|
globalRouteHint: '切换后对所有会话的后续请求生效',
|
|
30
30
|
showQuickSwitchLabel: '在输入栏显示快速路由切换按钮',
|
|
31
31
|
showQuickSwitchDescription: '开启后,当当前模型是 model-router 路由时,在输入框模型选择器右侧显示 provider 快速切换按钮。',
|
|
32
|
+
ignorePrefixLabel: '匹配时忽略模型 ID 前缀',
|
|
33
|
+
ignorePrefixDescription: '开启后,deepseek/deepseek-v4-flash 与 deepseek-v4-flash 视为同一模型,统一显示不带前缀的模型 id(默认开启)。',
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export const en = {
|
|
@@ -56,4 +58,6 @@ export const en = {
|
|
|
56
58
|
globalRouteHint: 'Changes apply to subsequent requests in all sessions',
|
|
57
59
|
showQuickSwitchLabel: 'Show quick route switcher in the composer',
|
|
58
60
|
showQuickSwitchDescription: 'When enabled, a provider quick-switch button appears next to the model selector when the current model is a model-router route.',
|
|
61
|
+
ignorePrefixLabel: 'Ignore model ID prefix when matching',
|
|
62
|
+
ignorePrefixDescription: 'When enabled, deepseek/deepseek-v4-flash and deepseek-v4-flash are treated as the same model and shown without the prefix (on by default).',
|
|
59
63
|
}
|