@fonlan/dsh-model-router 0.3.1 → 0.3.4

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
@@ -1,5 +1,4 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
3
2
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
3
  import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
5
4
  //#region src/shared/config.ts
@@ -59,6 +58,48 @@ function displayProvider(merged, active) {
59
58
  if (merged.providers.length === 0) return null;
60
59
  return merged.providers.find((p) => p.provider === active) ?? merged.providers[0];
61
60
  }
61
+ /** The display name a merged model advertises (active provider's name). */
62
+ function displayNameOf(model, config) {
63
+ const ids = model.providers.map((provider) => provider.provider);
64
+ return displayProvider(model, resolveActive(config, model.id, ids) ?? void 0)?.name ?? model.id;
65
+ }
66
+ /**
67
+ * Order the merged catalog the way the model picker should list it, per the
68
+ * configured `modelSort` mode. Always returns a permutation of `models`
69
+ * (never drops or duplicates entries).
70
+ *
71
+ * - `custom`: `config.modelOrder` first (in stored order), then any model not
72
+ * present in it, in catalog order.
73
+ * - `name`: display-name ascending (numeric-aware, case-insensitive), ties
74
+ * keep catalog order (stable sort).
75
+ * - `recent`: most-recently-used first; never-used models sink to the tail in
76
+ * catalog order. Ties (equal timestamps, or both unused) keep catalog
77
+ * order (stable sort).
78
+ */
79
+ function sortModelsForList(models, config) {
80
+ if (config.modelSort === "name") {
81
+ const byName = new Map(models.map((model) => [model, displayNameOf(model, config)]));
82
+ return [...models].sort((left, right) => byName.get(left).localeCompare(byName.get(right), void 0, {
83
+ numeric: true,
84
+ sensitivity: "base"
85
+ }));
86
+ }
87
+ if (config.modelSort === "recent") {
88
+ const recent = config.recentlyUsed ?? {};
89
+ return [...models].sort((left, right) => (recent[right.id] ?? -Infinity) - (recent[left.id] ?? -Infinity));
90
+ }
91
+ const listed = [];
92
+ const seen = /* @__PURE__ */ new Set();
93
+ for (const id of config.modelOrder ?? []) {
94
+ if (seen.has(id)) continue;
95
+ const model = models.find((candidate) => candidate.id === id);
96
+ if (model === void 0) continue;
97
+ seen.add(id);
98
+ listed.push(model);
99
+ }
100
+ const tail = models.filter((model) => !seen.has(model.id));
101
+ return [...listed, ...tail];
102
+ }
62
103
  /** Initial config for a model the user never configured: first in order wins. */
63
104
  function initialConfigFor(providerIds) {
64
105
  if (providerIds.length === 0) return null;
@@ -159,6 +200,9 @@ function sameModels(a, b) {
159
200
  * for `active`, and `order` becomes the deduped union in exact-key-first
160
201
  * order. Stored keys no longer mapping to any merged model are dropped (this
161
202
  * also covers turning the flag off — split models re-initialize fresh).
203
+ *
204
+ * `modelOrder` (custom display order) is reconciled the same way: vanished
205
+ * ids are pruned, newly discovered models are appended in catalog order.
162
206
  */
163
207
  function reconcileConfig(config, models) {
164
208
  const keyToMerged = /* @__PURE__ */ new Map();
@@ -201,9 +245,22 @@ function reconcileConfig(config, models) {
201
245
  const entry = syncConfig(view, model.id, ids).models[model.id];
202
246
  if (entry !== void 0) next[model.id] = entry;
203
247
  }
248
+ const storedOrder = config.modelOrder;
249
+ let modelOrder;
250
+ let orderChanged = false;
251
+ if (storedOrder !== void 0) {
252
+ const catalogIds = new Set(models.map((model) => model.id));
253
+ modelOrder = storedOrder.filter((id) => catalogIds.has(id));
254
+ orderChanged = modelOrder.length !== storedOrder.length;
255
+ for (const model of models) if (!modelOrder.includes(model.id)) {
256
+ modelOrder.push(model.id);
257
+ orderChanged = true;
258
+ }
259
+ }
204
260
  return {
205
261
  models: next,
206
- changed: !sameModels(next, config.models)
262
+ modelOrder,
263
+ changed: !sameModels(next, config.models) || orderChanged
207
264
  };
208
265
  }
209
266
  /** Switch a model's active provider (no-op when already active). */
@@ -260,16 +317,28 @@ function normalizeConfig(value) {
260
317
  if (typeof value !== "object" || value === null) return {
261
318
  models,
262
319
  showQuickSwitch: true,
263
- ignoreModelIdPrefix: true
320
+ ignoreModelIdPrefix: true,
321
+ modelSort: "custom",
322
+ modelOrder: [],
323
+ recentlyUsed: {}
264
324
  };
265
325
  const root = value;
266
326
  const showQuickSwitch = root.showQuickSwitch !== false;
267
327
  const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
328
+ const modelSort = root.modelSort === "name" || root.modelSort === "recent" ? root.modelSort : "custom";
329
+ const modelOrder = Array.isArray(root.modelOrder) ? root.modelOrder.filter((x) => typeof x === "string") : [];
330
+ const recentlyUsed = {};
331
+ if (typeof root.recentlyUsed === "object" && root.recentlyUsed !== null) {
332
+ for (const [id, ts] of Object.entries(root.recentlyUsed)) if (typeof ts === "number" && Number.isFinite(ts)) recentlyUsed[id] = ts;
333
+ }
268
334
  const raw = root.models;
269
335
  if (typeof raw !== "object" || raw === null) return {
270
336
  models,
271
337
  showQuickSwitch,
272
- ignoreModelIdPrefix
338
+ ignoreModelIdPrefix,
339
+ modelSort,
340
+ modelOrder,
341
+ recentlyUsed
273
342
  };
274
343
  for (const [id, entry] of Object.entries(raw)) {
275
344
  if (typeof entry !== "object" || entry === null) continue;
@@ -285,7 +354,10 @@ function normalizeConfig(value) {
285
354
  return {
286
355
  models,
287
356
  showQuickSwitch,
288
- ignoreModelIdPrefix
357
+ ignoreModelIdPrefix,
358
+ modelSort,
359
+ modelOrder,
360
+ recentlyUsed
289
361
  };
290
362
  }
291
363
  //#endregion
@@ -417,6 +489,22 @@ function routeFor(facts, modelId) {
417
489
  function entryRawId(merged, providerId) {
418
490
  return merged.providers.find((provider) => provider.provider === providerId)?.id ?? merged.id;
419
491
  }
492
+ function unroutable(model) {
493
+ return new LlmError(`model-router: no provider serves model "${model}"`, "NO_ADAPTER");
494
+ }
495
+ /**
496
+ * Resolve one model id to its active provider and merged model, rejecting
497
+ * unadvertised or currently unroutable ids before any provider I/O.
498
+ */
499
+ function bindRoute(facts, modelId) {
500
+ const merged = mergedFor(facts, modelId);
501
+ const route = merged === null ? null : routeFor(facts, modelId);
502
+ if (merged === null || route === null) throw unroutable(modelId);
503
+ return {
504
+ route,
505
+ merged
506
+ };
507
+ }
420
508
  var ModelRouterAdapter = class extends LlmAdapter {
421
509
  facts;
422
510
  constructor(facts) {
@@ -430,10 +518,11 @@ var ModelRouterAdapter = class extends LlmAdapter {
430
518
  };
431
519
  }
432
520
  async listModels(_provider) {
521
+ const config = this.facts.config();
433
522
  const { models } = this.facts.catalog();
434
523
  const names = this.facts.providerNames();
435
524
  const out = [];
436
- for (const merged of models) {
525
+ for (const merged of sortModelsForList(models, config)) {
437
526
  const display = displayProvider(merged, routeFor(this.facts, merged.id)?.provider);
438
527
  if (display === null) continue;
439
528
  out.push({
@@ -446,9 +535,32 @@ var ModelRouterAdapter = class extends LlmAdapter {
446
535
  return out;
447
536
  }
448
537
  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");
538
+ const { route, merged } = bindRoute(this.facts, model);
539
+ return this.resolveRoutedIdentity(route, merged, signal);
540
+ }
541
+ /**
542
+ * Bind the route generation at preparation time: a provider switch or
543
+ * catalog refresh between preparation and dispatch cannot combine one
544
+ * generation's metadata with another's endpoint, matching the host's
545
+ * one-generation dispatch contract.
546
+ */
547
+ async prepareCall(_provider, model, signal) {
548
+ const { route, merged } = bindRoute(this.facts, model);
549
+ return {
550
+ model: await this.resolveRoutedIdentity(route, merged, signal),
551
+ stream: (options) => this.streamRouted(options, route, merged)
552
+ };
553
+ }
554
+ async *stream(options) {
555
+ const { route, merged } = bindRoute(this.facts, options.model);
556
+ yield* this.streamRouted(options, route, merged);
557
+ }
558
+ /**
559
+ * Delegate with the provider's raw local id (it may carry a vendor/
560
+ * prefix the real provider needs), then normalize the resolved identity
561
+ * back to the merged display id so sessions/metering never leak prefixes.
562
+ */
563
+ async resolveRoutedIdentity(route, merged, signal) {
452
564
  const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal);
453
565
  const display = displayProvider(merged, route.provider);
454
566
  return {
@@ -458,10 +570,10 @@ var ModelRouterAdapter = class extends LlmAdapter {
458
570
  name: display?.name ?? resolved.name
459
571
  };
460
572
  }
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");
573
+ async *streamRouted(options, route, merged) {
574
+ try {
575
+ this.facts.noteModelUsed(merged.id);
576
+ } catch {}
465
577
  yield* this.facts.llm().stream({
466
578
  ...options,
467
579
  provider: route.provider,
@@ -471,7 +583,7 @@ var ModelRouterAdapter = class extends LlmAdapter {
471
583
  };
472
584
  //#endregion
473
585
  //#region src/server/service.ts
474
- const NS = settingsNamespace(ROUTER_SETTINGS_NS);
586
+ const NS = ROUTER_SETTINGS_NS;
475
587
  /** Settings document schema: one entry per model id. */
476
588
  const RouterConfigSchema = z.object({
477
589
  models: z.dict(z.object({
@@ -479,12 +591,22 @@ const RouterConfigSchema = z.object({
479
591
  active: z.string()
480
592
  })),
481
593
  showQuickSwitch: z.boolean(),
482
- ignoreModelIdPrefix: z.boolean()
594
+ ignoreModelIdPrefix: z.boolean(),
595
+ modelSort: z.union([
596
+ z.const("custom"),
597
+ z.const("name"),
598
+ z.const("recent")
599
+ ]),
600
+ modelOrder: z.array(z.string()),
601
+ recentlyUsed: z.dict(z.number())
483
602
  });
484
603
  const EMPTY_CONFIG = {
485
604
  models: {},
486
605
  showQuickSwitch: true,
487
- ignoreModelIdPrefix: true
606
+ ignoreModelIdPrefix: true,
607
+ modelSort: "custom",
608
+ modelOrder: [],
609
+ recentlyUsed: {}
488
610
  };
489
611
  var ModelRouterService = class {
490
612
  ctx;
@@ -501,6 +623,11 @@ var ModelRouterService = class {
501
623
  registration;
502
624
  /** How long a built catalog is considered fresh before being rebuilt. */
503
625
  catalogTtlMs = 3e4;
626
+ /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
627
+ usageFlushMs = 1500;
628
+ usageDebounce = null;
629
+ pendingUsage = {};
630
+ usageFlushRunning = false;
504
631
  constructor(ctx) {
505
632
  this.ctx = ctx;
506
633
  }
@@ -517,6 +644,11 @@ var ModelRouterService = class {
517
644
  this.refreshAndReconcile();
518
645
  }
519
646
  stop() {
647
+ if (this.usageDebounce !== null) {
648
+ clearTimeout(this.usageDebounce);
649
+ this.usageDebounce = null;
650
+ }
651
+ if (Object.keys(this.pendingUsage).length > 0) this.flushUsage();
520
652
  this.registration?.();
521
653
  this.registration = void 0;
522
654
  }
@@ -541,6 +673,47 @@ var ModelRouterService = class {
541
673
  return this.ctx.llm;
542
674
  }
543
675
  /**
676
+ * Record one model request for the `recent` display order. Fire-and-forget
677
+ * (the adapter never awaits it): timestamps accumulate in memory and are
678
+ * persisted debounced, so a burst of requests coalesces into one settings
679
+ * write.
680
+ */
681
+ noteModelUsed(modelId) {
682
+ if (this.scope === void 0) return;
683
+ this.pendingUsage[modelId] = Date.now();
684
+ if (this.usageDebounce !== null) return;
685
+ this.usageDebounce = setTimeout(() => {
686
+ this.usageDebounce = null;
687
+ this.flushUsage();
688
+ }, this.usageFlushMs);
689
+ }
690
+ /** Persist accumulated usage timestamps (merging with any concurrent writes). */
691
+ async flushUsage() {
692
+ if (this.usageFlushRunning) return;
693
+ this.usageFlushRunning = true;
694
+ const pending = this.pendingUsage;
695
+ this.pendingUsage = {};
696
+ try {
697
+ if (this.scope === void 0 || Object.keys(pending).length === 0) return;
698
+ const current = this.config();
699
+ await this.scope.replace({
700
+ ...current,
701
+ recentlyUsed: {
702
+ ...current.recentlyUsed,
703
+ ...pending
704
+ }
705
+ });
706
+ } catch (error) {
707
+ this.pendingUsage = {
708
+ ...pending,
709
+ ...this.pendingUsage
710
+ };
711
+ this.ctx.logger.warn(`model-router: usage persist failed: ${error instanceof Error ? error.message : String(error)}`);
712
+ } finally {
713
+ this.usageFlushRunning = false;
714
+ }
715
+ }
716
+ /**
544
717
  * Return the live catalog, rebuilding it only when it is stale (older than
545
718
  * `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
546
719
  * build; a failed build keeps the last good catalog.
@@ -572,13 +745,13 @@ var ModelRouterService = class {
572
745
  const catalog = await this.refreshCatalog(true);
573
746
  if (this.scope === void 0) return;
574
747
  const current = this.config();
575
- const { models, changed } = reconcileConfig(current, catalog.models);
748
+ const { models, modelOrder, changed } = reconcileConfig(current, catalog.models);
576
749
  if (!changed) return;
577
750
  try {
578
751
  await this.scope.replace({
752
+ ...current,
579
753
  models,
580
- showQuickSwitch: current.showQuickSwitch,
581
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
754
+ ...modelOrder === void 0 ? {} : { modelOrder }
582
755
  });
583
756
  } catch (error) {
584
757
  this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -599,12 +772,14 @@ var ModelRouterService = class {
599
772
  return {
600
773
  showQuickSwitch: config.showQuickSwitch,
601
774
  ignoreModelIdPrefix: config.ignoreModelIdPrefix,
775
+ modelSort: config.modelSort,
776
+ modelOrder: config.modelOrder,
602
777
  providers: catalog.providers.map((provider) => ({
603
778
  id: provider.id,
604
779
  name: names.get(provider.id) ?? provider.id,
605
780
  credentialConfigured: creds.get(provider.id) ?? true
606
781
  })),
607
- models: catalog.models.map((model) => {
782
+ models: sortModelsForList(catalog.models, config).map((model) => {
608
783
  const ids = model.providers.map((provider) => provider.provider);
609
784
  const stored = config.models[model.id] ?? initialConfigFor(ids);
610
785
  const active = resolveActive(config, model.id, ids);
@@ -647,9 +822,8 @@ var ModelRouterService = class {
647
822
  const current = this.config();
648
823
  if (current.showQuickSwitch === value) return;
649
824
  await this.scope.replace({
650
- models: current.models,
651
- showQuickSwitch: value,
652
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
825
+ ...current,
826
+ showQuickSwitch: value
653
827
  });
654
828
  }
655
829
  /**
@@ -663,19 +837,45 @@ var ModelRouterService = class {
663
837
  const current = this.config();
664
838
  if (current.ignoreModelIdPrefix === value) return;
665
839
  await this.scope.replace({
666
- models: current.models,
667
- showQuickSwitch: current.showQuickSwitch,
840
+ ...current,
668
841
  ignoreModelIdPrefix: value
669
842
  });
670
843
  await this.refreshAndReconcile();
671
844
  }
845
+ /** Switch the model-list display order mode (global, persisted). */
846
+ async setModelSort(mode) {
847
+ if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
848
+ const current = this.config();
849
+ if (current.modelSort === mode) return;
850
+ await this.scope.replace({
851
+ ...current,
852
+ modelSort: mode
853
+ });
854
+ }
855
+ /**
856
+ * Replace the explicit custom model order (global, persisted). Vanished
857
+ * ids are pruned and missing catalog models appended, so the stored order
858
+ * always covers the live catalog.
859
+ */
860
+ async setModelOrder(order) {
861
+ if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
862
+ await this.refreshCatalog();
863
+ const ids = new Set(this.cached.models.map((model) => model.id));
864
+ const clean = order.filter((id) => ids.has(id));
865
+ for (const model of this.cached.models) if (!clean.includes(model.id)) clean.push(model.id);
866
+ const current = this.config();
867
+ if (clean.length === current.modelOrder.length && clean.every((id, at) => id === current.modelOrder[at])) return;
868
+ await this.scope.replace({
869
+ ...current,
870
+ modelOrder: clean
871
+ });
872
+ }
672
873
  async persist(models) {
673
874
  if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
674
875
  const current = this.config();
675
876
  await this.scope.replace({
676
- models,
677
- showQuickSwitch: current.showQuickSwitch,
678
- ignoreModelIdPrefix: current.ignoreModelIdPrefix
877
+ ...current,
878
+ models
679
879
  });
680
880
  }
681
881
  };
@@ -801,6 +1001,16 @@ function registerApiRoutes(ctx, service) {
801
1001
  await service.setIgnoreModelIdPrefix(p.value);
802
1002
  return service.state();
803
1003
  });
1004
+ route("set-model-sort", async (p) => {
1005
+ if (p.mode !== "custom" && p.mode !== "name" && p.mode !== "recent") throw new Error("mode 必须是 custom、name 或 recent");
1006
+ await service.setModelSort(p.mode);
1007
+ return service.state();
1008
+ });
1009
+ route("set-model-order", async (p) => {
1010
+ if (!Array.isArray(p.order) || !p.order.every((id) => typeof id === "string")) throw new Error("order 必须是模型 id 数组");
1011
+ await service.setModelOrder(p.order);
1012
+ return service.state();
1013
+ });
804
1014
  return () => {
805
1015
  for (const dispose of disposers) dispose();
806
1016
  };
@@ -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,18 +5,22 @@
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
- export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
11
+ export declare const NS = "model-router";
12
12
  /** Settings document schema: one entry per model id. */
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). */