@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/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
- /** Merge every provider's models into one entry per exact model id. */
9
- function mergeModels(providers) {
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) for (const model of provider.models) {
12
- let merged = byId.get(model.id);
13
- if (merged === void 0) {
14
- merged = {
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
- providers: []
17
- };
18
- byId.set(model.id, merged);
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 { models };
164
- const raw = value.models;
165
- if (typeof raw !== "object" || raw === null) return { models };
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 { models };
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.catalog().byId.get(modelId);
281
- if (merged === void 0) return null;
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(), modelId, ids);
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
- if (route === null) throw new LlmError(`model-router: no provider serves model "${model}"`, "NO_ADAPTER");
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
- ...await this.facts.llm().resolveModelInfo(route.provider, model, signal),
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
- if (route === null) throw new LlmError(`model-router: no provider serves model "${options.model}"`, "NO_ADAPTER");
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({ models: z.dict(z.object({
336
- order: z.array(z.string()),
337
- active: z.string()
338
- })) });
339
- const EMPTY_CONFIG = { models: {} };
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 providers/models, vanish). */
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 next = { ...current.models };
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({ models: next });
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
- await this.scope.replace({ models });
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 by exact id
4
- * through the shared core, and per-provider credential status is detected
5
- * generically through the configurable-provider directory + the credentials
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 by exact id across providers. */
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 providers/models, vanish). */
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
- * - Strict merge: models are merged across providers by exact model id only.
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 (== merged model id, strict merge). */
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 (strict id merge). */
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
- /** Merge every provider's models into one entry per exact model id. */
58
- export declare function mergeModels(providers: readonly ProviderInfo[]): MergedModel[];
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.0",
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"