@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/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}`);
@@ -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.catalog().byId.get(modelId);
292
- if (merged === void 0) return null;
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(), modelId, ids);
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
- 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);
328
454
  return {
329
- ...await this.facts.llm().resolveModelInfo(route.provider, model, signal),
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
- 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");
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 providers/models, vanish). */
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 next = { ...current.models };
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: next,
444
- showQuickSwitch: this.config().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: this.config().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 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>;
@@ -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 providers/models, vanish). */
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
- * - 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[];
@@ -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
- /** Merge every provider's models into one entry per exact model id. */
64
- 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[];
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.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": {
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
  }
@@ -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
  }