@fonlan/dsh-model-router 0.3.1 → 0.3.3

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
@@ -59,6 +59,48 @@ function displayProvider(merged, active) {
59
59
  if (merged.providers.length === 0) return null;
60
60
  return merged.providers.find((p) => p.provider === active) ?? merged.providers[0];
61
61
  }
62
+ /** The display name a merged model advertises (active provider's name). */
63
+ function displayNameOf(model, config) {
64
+ const ids = model.providers.map((provider) => provider.provider);
65
+ return displayProvider(model, resolveActive(config, model.id, ids) ?? void 0)?.name ?? model.id;
66
+ }
67
+ /**
68
+ * Order the merged catalog the way the model picker should list it, per the
69
+ * configured `modelSort` mode. Always returns a permutation of `models`
70
+ * (never drops or duplicates entries).
71
+ *
72
+ * - `custom`: `config.modelOrder` first (in stored order), then any model not
73
+ * present in it, in catalog order.
74
+ * - `name`: display-name ascending (numeric-aware, case-insensitive), ties
75
+ * keep catalog order (stable sort).
76
+ * - `recent`: most-recently-used first; never-used models sink to the tail in
77
+ * catalog order. Ties (equal timestamps, or both unused) keep catalog
78
+ * order (stable sort).
79
+ */
80
+ function sortModelsForList(models, config) {
81
+ if (config.modelSort === "name") {
82
+ const byName = new Map(models.map((model) => [model, displayNameOf(model, config)]));
83
+ return [...models].sort((left, right) => byName.get(left).localeCompare(byName.get(right), void 0, {
84
+ numeric: true,
85
+ sensitivity: "base"
86
+ }));
87
+ }
88
+ if (config.modelSort === "recent") {
89
+ const recent = config.recentlyUsed ?? {};
90
+ return [...models].sort((left, right) => (recent[right.id] ?? -Infinity) - (recent[left.id] ?? -Infinity));
91
+ }
92
+ const listed = [];
93
+ const seen = /* @__PURE__ */ new Set();
94
+ for (const id of config.modelOrder ?? []) {
95
+ if (seen.has(id)) continue;
96
+ const model = models.find((candidate) => candidate.id === id);
97
+ if (model === void 0) continue;
98
+ seen.add(id);
99
+ listed.push(model);
100
+ }
101
+ const tail = models.filter((model) => !seen.has(model.id));
102
+ return [...listed, ...tail];
103
+ }
62
104
  /** Initial config for a model the user never configured: first in order wins. */
63
105
  function initialConfigFor(providerIds) {
64
106
  if (providerIds.length === 0) return null;
@@ -159,6 +201,9 @@ function sameModels(a, b) {
159
201
  * for `active`, and `order` becomes the deduped union in exact-key-first
160
202
  * order. Stored keys no longer mapping to any merged model are dropped (this
161
203
  * also covers turning the flag off — split models re-initialize fresh).
204
+ *
205
+ * `modelOrder` (custom display order) is reconciled the same way: vanished
206
+ * ids are pruned, newly discovered models are appended in catalog order.
162
207
  */
163
208
  function reconcileConfig(config, models) {
164
209
  const keyToMerged = /* @__PURE__ */ new Map();
@@ -201,9 +246,22 @@ function reconcileConfig(config, models) {
201
246
  const entry = syncConfig(view, model.id, ids).models[model.id];
202
247
  if (entry !== void 0) next[model.id] = entry;
203
248
  }
249
+ const storedOrder = config.modelOrder;
250
+ let modelOrder;
251
+ let orderChanged = false;
252
+ if (storedOrder !== void 0) {
253
+ const catalogIds = new Set(models.map((model) => model.id));
254
+ modelOrder = storedOrder.filter((id) => catalogIds.has(id));
255
+ orderChanged = modelOrder.length !== storedOrder.length;
256
+ for (const model of models) if (!modelOrder.includes(model.id)) {
257
+ modelOrder.push(model.id);
258
+ orderChanged = true;
259
+ }
260
+ }
204
261
  return {
205
262
  models: next,
206
- changed: !sameModels(next, config.models)
263
+ modelOrder,
264
+ changed: !sameModels(next, config.models) || orderChanged
207
265
  };
208
266
  }
209
267
  /** Switch a model's active provider (no-op when already active). */
@@ -260,16 +318,28 @@ function normalizeConfig(value) {
260
318
  if (typeof value !== "object" || value === null) return {
261
319
  models,
262
320
  showQuickSwitch: true,
263
- ignoreModelIdPrefix: true
321
+ ignoreModelIdPrefix: true,
322
+ modelSort: "custom",
323
+ modelOrder: [],
324
+ recentlyUsed: {}
264
325
  };
265
326
  const root = value;
266
327
  const showQuickSwitch = root.showQuickSwitch !== false;
267
328
  const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
329
+ const modelSort = root.modelSort === "name" || root.modelSort === "recent" ? root.modelSort : "custom";
330
+ const modelOrder = Array.isArray(root.modelOrder) ? root.modelOrder.filter((x) => typeof x === "string") : [];
331
+ const recentlyUsed = {};
332
+ if (typeof root.recentlyUsed === "object" && root.recentlyUsed !== null) {
333
+ for (const [id, ts] of Object.entries(root.recentlyUsed)) if (typeof ts === "number" && Number.isFinite(ts)) recentlyUsed[id] = ts;
334
+ }
268
335
  const raw = root.models;
269
336
  if (typeof raw !== "object" || raw === null) return {
270
337
  models,
271
338
  showQuickSwitch,
272
- ignoreModelIdPrefix
339
+ ignoreModelIdPrefix,
340
+ modelSort,
341
+ modelOrder,
342
+ recentlyUsed
273
343
  };
274
344
  for (const [id, entry] of Object.entries(raw)) {
275
345
  if (typeof entry !== "object" || entry === null) continue;
@@ -285,7 +355,10 @@ function normalizeConfig(value) {
285
355
  return {
286
356
  models,
287
357
  showQuickSwitch,
288
- ignoreModelIdPrefix
358
+ ignoreModelIdPrefix,
359
+ modelSort,
360
+ modelOrder,
361
+ recentlyUsed
289
362
  };
290
363
  }
291
364
  //#endregion
@@ -417,6 +490,22 @@ function routeFor(facts, modelId) {
417
490
  function entryRawId(merged, providerId) {
418
491
  return merged.providers.find((provider) => provider.provider === providerId)?.id ?? merged.id;
419
492
  }
493
+ function unroutable(model) {
494
+ return new LlmError(`model-router: no provider serves model "${model}"`, "NO_ADAPTER");
495
+ }
496
+ /**
497
+ * Resolve one model id to its active provider and merged model, rejecting
498
+ * unadvertised or currently unroutable ids before any provider I/O.
499
+ */
500
+ function bindRoute(facts, modelId) {
501
+ const merged = mergedFor(facts, modelId);
502
+ const route = merged === null ? null : routeFor(facts, modelId);
503
+ if (merged === null || route === null) throw unroutable(modelId);
504
+ return {
505
+ route,
506
+ merged
507
+ };
508
+ }
420
509
  var ModelRouterAdapter = class extends LlmAdapter {
421
510
  facts;
422
511
  constructor(facts) {
@@ -430,10 +519,11 @@ var ModelRouterAdapter = class extends LlmAdapter {
430
519
  };
431
520
  }
432
521
  async listModels(_provider) {
522
+ const config = this.facts.config();
433
523
  const { models } = this.facts.catalog();
434
524
  const names = this.facts.providerNames();
435
525
  const out = [];
436
- for (const merged of models) {
526
+ for (const merged of sortModelsForList(models, config)) {
437
527
  const display = displayProvider(merged, routeFor(this.facts, merged.id)?.provider);
438
528
  if (display === null) continue;
439
529
  out.push({
@@ -446,9 +536,32 @@ var ModelRouterAdapter = class extends LlmAdapter {
446
536
  return out;
447
537
  }
448
538
  async resolveModel(_provider, model, signal) {
449
- const route = routeFor(this.facts, model);
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");
539
+ const { route, merged } = bindRoute(this.facts, model);
540
+ return this.resolveRoutedIdentity(route, merged, signal);
541
+ }
542
+ /**
543
+ * Bind the route generation at preparation time: a provider switch or
544
+ * catalog refresh between preparation and dispatch cannot combine one
545
+ * generation's metadata with another's endpoint, matching the host's
546
+ * one-generation dispatch contract.
547
+ */
548
+ async prepareCall(_provider, model, signal) {
549
+ const { route, merged } = bindRoute(this.facts, model);
550
+ return {
551
+ model: await this.resolveRoutedIdentity(route, merged, signal),
552
+ stream: (options) => this.streamRouted(options, route, merged)
553
+ };
554
+ }
555
+ async *stream(options) {
556
+ const { route, merged } = bindRoute(this.facts, options.model);
557
+ yield* this.streamRouted(options, route, merged);
558
+ }
559
+ /**
560
+ * Delegate with the provider's raw local id (it may carry a vendor/
561
+ * prefix the real provider needs), then normalize the resolved identity
562
+ * back to the merged display id so sessions/metering never leak prefixes.
563
+ */
564
+ async resolveRoutedIdentity(route, merged, signal) {
452
565
  const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal);
453
566
  const display = displayProvider(merged, route.provider);
454
567
  return {
@@ -458,10 +571,10 @@ var ModelRouterAdapter = class extends LlmAdapter {
458
571
  name: display?.name ?? resolved.name
459
572
  };
460
573
  }
461
- async *stream(options) {
462
- const route = routeFor(this.facts, options.model);
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");
574
+ async *streamRouted(options, route, merged) {
575
+ try {
576
+ this.facts.noteModelUsed(merged.id);
577
+ } catch {}
465
578
  yield* this.facts.llm().stream({
466
579
  ...options,
467
580
  provider: route.provider,
@@ -479,12 +592,22 @@ const RouterConfigSchema = z.object({
479
592
  active: z.string()
480
593
  })),
481
594
  showQuickSwitch: z.boolean(),
482
- ignoreModelIdPrefix: z.boolean()
595
+ ignoreModelIdPrefix: z.boolean(),
596
+ modelSort: z.union([
597
+ z.const("custom"),
598
+ z.const("name"),
599
+ z.const("recent")
600
+ ]),
601
+ modelOrder: z.array(z.string()),
602
+ recentlyUsed: z.dict(z.number())
483
603
  });
484
604
  const EMPTY_CONFIG = {
485
605
  models: {},
486
606
  showQuickSwitch: true,
487
- ignoreModelIdPrefix: true
607
+ ignoreModelIdPrefix: true,
608
+ modelSort: "custom",
609
+ modelOrder: [],
610
+ recentlyUsed: {}
488
611
  };
489
612
  var ModelRouterService = class {
490
613
  ctx;
@@ -501,6 +624,11 @@ var ModelRouterService = class {
501
624
  registration;
502
625
  /** How long a built catalog is considered fresh before being rebuilt. */
503
626
  catalogTtlMs = 3e4;
627
+ /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
628
+ usageFlushMs = 1500;
629
+ usageDebounce = null;
630
+ pendingUsage = {};
631
+ usageFlushRunning = false;
504
632
  constructor(ctx) {
505
633
  this.ctx = ctx;
506
634
  }
@@ -517,6 +645,11 @@ var ModelRouterService = class {
517
645
  this.refreshAndReconcile();
518
646
  }
519
647
  stop() {
648
+ if (this.usageDebounce !== null) {
649
+ clearTimeout(this.usageDebounce);
650
+ this.usageDebounce = null;
651
+ }
652
+ if (Object.keys(this.pendingUsage).length > 0) this.flushUsage();
520
653
  this.registration?.();
521
654
  this.registration = void 0;
522
655
  }
@@ -541,6 +674,47 @@ var ModelRouterService = class {
541
674
  return this.ctx.llm;
542
675
  }
543
676
  /**
677
+ * Record one model request for the `recent` display order. Fire-and-forget
678
+ * (the adapter never awaits it): timestamps accumulate in memory and are
679
+ * persisted debounced, so a burst of requests coalesces into one settings
680
+ * write.
681
+ */
682
+ noteModelUsed(modelId) {
683
+ if (this.scope === void 0) return;
684
+ this.pendingUsage[modelId] = Date.now();
685
+ if (this.usageDebounce !== null) return;
686
+ this.usageDebounce = setTimeout(() => {
687
+ this.usageDebounce = null;
688
+ this.flushUsage();
689
+ }, this.usageFlushMs);
690
+ }
691
+ /** Persist accumulated usage timestamps (merging with any concurrent writes). */
692
+ async flushUsage() {
693
+ if (this.usageFlushRunning) return;
694
+ this.usageFlushRunning = true;
695
+ const pending = this.pendingUsage;
696
+ this.pendingUsage = {};
697
+ try {
698
+ if (this.scope === void 0 || Object.keys(pending).length === 0) return;
699
+ const current = this.config();
700
+ await this.scope.replace({
701
+ ...current,
702
+ recentlyUsed: {
703
+ ...current.recentlyUsed,
704
+ ...pending
705
+ }
706
+ });
707
+ } catch (error) {
708
+ this.pendingUsage = {
709
+ ...pending,
710
+ ...this.pendingUsage
711
+ };
712
+ this.ctx.logger.warn(`model-router: usage persist failed: ${error instanceof Error ? error.message : String(error)}`);
713
+ } finally {
714
+ this.usageFlushRunning = false;
715
+ }
716
+ }
717
+ /**
544
718
  * Return the live catalog, rebuilding it only when it is stale (older than
545
719
  * `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
546
720
  * build; a failed build keeps the last good catalog.
@@ -572,13 +746,13 @@ var ModelRouterService = class {
572
746
  const catalog = await this.refreshCatalog(true);
573
747
  if (this.scope === void 0) return;
574
748
  const current = this.config();
575
- const { models, changed } = reconcileConfig(current, catalog.models);
749
+ const { models, modelOrder, changed } = reconcileConfig(current, catalog.models);
576
750
  if (!changed) return;
577
751
  try {
578
752
  await this.scope.replace({
753
+ ...current,
579
754
  models,
580
- showQuickSwitch: current.showQuickSwitch,
581
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
755
+ ...modelOrder === void 0 ? {} : { modelOrder }
582
756
  });
583
757
  } catch (error) {
584
758
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -599,12 +773,14 @@ var ModelRouterService = class {
599
773
  return {
600
774
  showQuickSwitch: config.showQuickSwitch,
601
775
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
776
+ modelSort: config.modelSort,
777
+ modelOrder: config.modelOrder,
602
778
  providers: catalog.providers.map((provider) => ({
603
779
  id: provider.id,
604
780
  name: names.get(provider.id) ?? provider.id,
605
781
  credentialConfigured: creds.get(provider.id) ?? true
606
782
  })),
607
- models: catalog.models.map((model) => {
783
+ models: sortModelsForList(catalog.models, config).map((model) => {
608
784
  const ids = model.providers.map((provider) => provider.provider);
609
785
  const stored = config.models[model.id] ?? initialConfigFor(ids);
610
786
  const active = resolveActive(config, model.id, ids);
@@ -647,9 +823,8 @@ var ModelRouterService = class {
647
823
  const current = this.config();
648
824
  if (current.showQuickSwitch === value) return;
649
825
  await this.scope.replace({
650
- models: current.models,
651
- showQuickSwitch: value,
652
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
826
+ ...current,
827
+ showQuickSwitch: value
653
828
  });
654
829
  }
655
830
  /**
@@ -663,19 +838,45 @@ var ModelRouterService = class {
663
838
  const current = this.config();
664
839
  if (current.ignoreModelIdPrefix === value) return;
665
840
  await this.scope.replace({
666
- models: current.models,
667
- showQuickSwitch: current.showQuickSwitch,
841
+ ...current,
668
842
  ignoreModelIdPrefix: value
669
843
  });
670
844
  await this.refreshAndReconcile();
671
845
  }
846
+ /** Switch the model-list display order mode (global, persisted). */
847
+ async setModelSort(mode) {
848
+ if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
849
+ const current = this.config();
850
+ if (current.modelSort === mode) return;
851
+ await this.scope.replace({
852
+ ...current,
853
+ modelSort: mode
854
+ });
855
+ }
856
+ /**
857
+ * Replace the explicit custom model order (global, persisted). Vanished
858
+ * ids are pruned and missing catalog models appended, so the stored order
859
+ * always covers the live catalog.
860
+ */
861
+ async setModelOrder(order) {
862
+ if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
863
+ await this.refreshCatalog();
864
+ const ids = new Set(this.cached.models.map((model) => model.id));
865
+ const clean = order.filter((id) => ids.has(id));
866
+ for (const model of this.cached.models) if (!clean.includes(model.id)) clean.push(model.id);
867
+ const current = this.config();
868
+ if (clean.length === current.modelOrder.length && clean.every((id, at) => id === current.modelOrder[at])) return;
869
+ await this.scope.replace({
870
+ ...current,
871
+ modelOrder: clean
872
+ });
873
+ }
672
874
  async persist(models) {
673
875
  if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
674
876
  const current = this.config();
675
877
  await this.scope.replace({
676
- models,
677
- showQuickSwitch: current.showQuickSwitch,
678
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
878
+ ...current,
879
+ models
679
880
  });
680
881
  }
681
882
  };
@@ -801,6 +1002,16 @@ function registerApiRoutes(ctx, service) {
801
1002
  await service.setIgnoreModelIdPrefix(p.value);
802
1003
  return service.state();
803
1004
  });
1005
+ route("set-model-sort", async (p) => {
1006
+ if (p.mode !== "custom" && p.mode !== "name" && p.mode !== "recent") throw new Error("mode 必须是 custom、name 或 recent");
1007
+ await service.setModelSort(p.mode);
1008
+ return service.state();
1009
+ });
1010
+ route("set-model-order", async (p) => {
1011
+ if (!Array.isArray(p.order) || !p.order.every((id) => typeof id === "string")) throw new Error("order 必须是模型 id 数组");
1012
+ await service.setModelOrder(p.order);
1013
+ return service.state();
1014
+ });
804
1015
  return () => {
805
1016
  for (const dispose of disposers) dispose();
806
1017
  };
@@ -8,7 +8,7 @@
8
8
  * flag is on, and delegation always uses the active provider's raw local id.
9
9
  */
10
10
  import { LlmAdapter, type LlmRuntime } from '@deepseek-ai/dsh-llm';
11
- import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
11
+ import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm';
12
12
  import { type MergedModel, type RouterConfigShape } from '../shared/config.js';
13
13
  /** Facts the adapter reads from the owning service (live, per request). */
14
14
  export interface RouterFacts {
@@ -23,6 +23,8 @@ export interface RouterFacts {
23
23
  providerNames(): ReadonlyMap<string, string>;
24
24
  /** The LLM runtime to delegate to. */
25
25
  llm(): LlmRuntime;
26
+ /** Record one model request for the recent-use ordering (fire-and-forget). */
27
+ noteModelUsed(modelId: string): void;
26
28
  }
27
29
  /** One resolved route for a model id. */
28
30
  export interface ResolvedRoute {
@@ -44,11 +46,35 @@ export declare function mergedFor(facts: RouterFacts, modelId: string): MergedMo
44
46
  export declare function routeFor(facts: RouterFacts, modelId: string): ResolvedRoute | null;
45
47
  /** The raw provider-local id of one provider entry inside a merged model. */
46
48
  export declare function entryRawId(merged: MergedModel, providerId: string): string;
49
+ /** One validated route plus the merged model it was resolved against. */
50
+ export interface BoundRoute {
51
+ route: ResolvedRoute;
52
+ merged: MergedModel;
53
+ }
54
+ /**
55
+ * Resolve one model id to its active provider and merged model, rejecting
56
+ * unadvertised or currently unroutable ids before any provider I/O.
57
+ */
58
+ export declare function bindRoute(facts: RouterFacts, modelId: string): BoundRoute;
47
59
  export declare class ModelRouterAdapter extends LlmAdapter {
48
60
  private readonly facts;
49
61
  constructor(facts: RouterFacts);
50
62
  providerInfo(provider: string): LlmProviderInfo;
51
63
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
52
64
  resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
65
+ /**
66
+ * Bind the route generation at preparation time: a provider switch or
67
+ * catalog refresh between preparation and dispatch cannot combine one
68
+ * generation's metadata with another's endpoint, matching the host's
69
+ * one-generation dispatch contract.
70
+ */
71
+ prepareCall(_provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;
53
72
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
73
+ /**
74
+ * Delegate with the provider's raw local id (it may carry a vendor/
75
+ * prefix the real provider needs), then normalize the resolved identity
76
+ * back to the merged display id so sessions/metering never leak prefixes.
77
+ */
78
+ private resolveRoutedIdentity;
79
+ private streamRouted;
54
80
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { Context } from '@deepseek-ai/cordis';
7
7
  import z from '@deepseek-ai/schemastery';
8
- import { type RouterConfigShape } from '../shared/config.js';
8
+ import { type ModelSortMode, type RouterConfigShape } from '../shared/config.js';
9
9
  import { type RouterCatalog } from './catalog.js';
10
10
  import { type RouterFacts } from './adapter.js';
11
11
  export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
@@ -13,10 +13,14 @@ 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. */
16
+ /** Whether provider switching is shown inside the composer model picker menu. */
17
17
  showQuickSwitch: boolean;
18
18
  /** Whether model ids are matched with their leading vendor/ prefix ignored. */
19
19
  ignoreModelIdPrefix: boolean;
20
+ /** The model-list display order mode (custom | name | recent). */
21
+ modelSort: ModelSortMode;
22
+ /** Explicit model id order for custom mode (display order). */
23
+ modelOrder: string[];
20
24
  providers: Array<{
21
25
  id: string;
22
26
  name: string;
@@ -46,6 +50,11 @@ export declare class ModelRouterService implements RouterFacts {
46
50
  private registration;
47
51
  /** How long a built catalog is considered fresh before being rebuilt. */
48
52
  private readonly catalogTtlMs;
53
+ /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
54
+ private readonly usageFlushMs;
55
+ private usageDebounce;
56
+ private pendingUsage;
57
+ private usageFlushRunning;
49
58
  constructor(ctx: Context);
50
59
  /** Register settings + adapter and wire topology listeners. */
51
60
  start(): void;
@@ -57,6 +66,15 @@ export declare class ModelRouterService implements RouterFacts {
57
66
  config(): RouterConfigShape;
58
67
  providerNames(): ReadonlyMap<string, string>;
59
68
  llm(): import("@deepseek-ai/dsh-llm").LlmRuntime;
69
+ /**
70
+ * Record one model request for the `recent` display order. Fire-and-forget
71
+ * (the adapter never awaits it): timestamps accumulate in memory and are
72
+ * persisted debounced, so a burst of requests coalesces into one settings
73
+ * write.
74
+ */
75
+ noteModelUsed(modelId: string): void;
76
+ /** Persist accumulated usage timestamps (merging with any concurrent writes). */
77
+ private flushUsage;
60
78
  /**
61
79
  * Return the live catalog, rebuilding it only when it is stale (older than
62
80
  * `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
@@ -89,5 +107,13 @@ export declare class ModelRouterService implements RouterFacts {
89
107
  * turning on; split models re-initialize when turning off).
90
108
  */
91
109
  setIgnoreModelIdPrefix(value: boolean): Promise<void>;
110
+ /** Switch the model-list display order mode (global, persisted). */
111
+ setModelSort(mode: ModelSortMode): Promise<void>;
112
+ /**
113
+ * Replace the explicit custom model order (global, persisted). Vanished
114
+ * ids are pruned and missing catalog models appended, so the stored order
115
+ * always covers the live catalog.
116
+ */
117
+ setModelOrder(order: string[]): Promise<void>;
92
118
  private persist;
93
119
  }
@@ -59,12 +59,27 @@ export interface RouterModelConfig {
59
59
  /** The provider requests currently route to. */
60
60
  active: string;
61
61
  }
62
+ /**
63
+ * How the router orders the model list it advertises to the host model
64
+ * picker (the `model-router` group in DSH's model selection box).
65
+ *
66
+ * - `custom`: the explicit `modelOrder` array wins; models absent from it are
67
+ * appended in catalog (first-seen) order.
68
+ * - `name`: models sorted by their display name (active provider's name),
69
+ * falling back to the merged id.
70
+ * - `recent`: models sorted by most-recently-used first (timestamp desc);
71
+ * never-used models come last in catalog order.
72
+ *
73
+ * Absent/unknown documents normalize to `custom` (the pre-existing catalog
74
+ * order, so upgrading is a no-op until the user changes it).
75
+ */
76
+ export type ModelSortMode = 'custom' | 'name' | 'recent';
62
77
  /** The whole router config document. */
63
78
  export interface RouterConfigShape {
64
79
  models: Record<string, RouterModelConfig>;
65
80
  /**
66
- * Whether the quick route-switcher button is shown beside the composer
67
- * model selector (settings → 模型路由 → 显示快速路由切换按钮). Stored
81
+ * Whether the provider switching entry is shown inside the composer model
82
+ * picker menu (settings → 模型路由 → 在模型选择菜单中显示提供商切换). Stored
68
83
  * globally; absent/unknown documents normalize to `true`.
69
84
  */
70
85
  showQuickSwitch: boolean;
@@ -76,6 +91,23 @@ export interface RouterConfigShape {
76
91
  * `true`.
77
92
  */
78
93
  ignoreModelIdPrefix: boolean;
94
+ /**
95
+ * The model-list display order mode (settings → 模型路由 → 模型显示顺序).
96
+ * Absent/unknown documents normalize to `custom`.
97
+ */
98
+ modelSort: ModelSortMode;
99
+ /**
100
+ * Explicit model id order for `custom` mode, in display order. Kept
101
+ * reconciled against the catalog (vanished ids pruned, new ids appended).
102
+ * Absent/unknown documents normalize to `[]`.
103
+ */
104
+ modelOrder: string[];
105
+ /**
106
+ * Merged model id → last-use epoch millis, for `recent` mode. Written by
107
+ * the router adapter whenever a model request streams. Absent/unknown
108
+ * documents normalize to `{}`.
109
+ */
110
+ recentlyUsed: Record<string, number>;
79
111
  }
80
112
  export declare const ROUTER_PROVIDER_ID = "model-router";
81
113
  export declare const ROUTER_SETTINGS_NS = "model-router";
@@ -100,6 +132,22 @@ export interface MergeOptions {
100
132
  export declare function mergeModels(providers: readonly ProviderInfo[], options?: MergeOptions): MergedModel[];
101
133
  /** The provider view a merged model should display for (follows active). */
102
134
  export declare function displayProvider(merged: MergedModel, active: string | undefined): ProviderModelInfo | null;
135
+ /** The display name a merged model advertises (active provider's name). */
136
+ export declare function displayNameOf(model: MergedModel, config: RouterConfigShape): string;
137
+ /**
138
+ * Order the merged catalog the way the model picker should list it, per the
139
+ * configured `modelSort` mode. Always returns a permutation of `models`
140
+ * (never drops or duplicates entries).
141
+ *
142
+ * - `custom`: `config.modelOrder` first (in stored order), then any model not
143
+ * present in it, in catalog order.
144
+ * - `name`: display-name ascending (numeric-aware, case-insensitive), ties
145
+ * keep catalog order (stable sort).
146
+ * - `recent`: most-recently-used first; never-used models sink to the tail in
147
+ * catalog order. Ties (equal timestamps, or both unused) keep catalog
148
+ * order (stable sort).
149
+ */
150
+ export declare function sortModelsForList(models: readonly MergedModel[], config: RouterConfigShape): MergedModel[];
103
151
  /** Initial config for a model the user never configured: first in order wins. */
104
152
  export declare function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null;
105
153
  /**
@@ -131,9 +179,13 @@ export declare function syncConfig(config: RouterConfigShape, modelId: string, c
131
179
  * for `active`, and `order` becomes the deduped union in exact-key-first
132
180
  * order. Stored keys no longer mapping to any merged model are dropped (this
133
181
  * also covers turning the flag off — split models re-initialize fresh).
182
+ *
183
+ * `modelOrder` (custom display order) is reconciled the same way: vanished
184
+ * ids are pruned, newly discovered models are appended in catalog order.
134
185
  */
135
186
  export declare function reconcileConfig(config: RouterConfigShape, models: readonly MergedModel[]): {
136
187
  models: Record<string, RouterModelConfig>;
188
+ modelOrder: string[] | undefined;
137
189
  changed: boolean;
138
190
  };
139
191
  /** Switch a model's active provider (no-op when already active). */