@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/README.md +12 -5
- package/lib/client.js +655 -130
- package/lib/client.js.map +1 -1
- package/lib/index.js +238 -28
- package/lib/types/server/adapter.d.ts +27 -1
- package/lib/types/server/service.d.ts +29 -3
- package/lib/types/shared/config.d.ts +54 -2
- package/package.json +35 -33
- package/src/client/api.ts +11 -1
- package/src/client/index.tsx +6 -5
- package/src/client/locales.ts +56 -12
- package/src/client/model-seat.css +289 -0
- package/src/client/model-seat.tsx +626 -0
- package/src/client/settings-card.css +141 -0
- package/src/client/settings-card.tsx +112 -3
- package/src/server/adapter.ts +62 -13
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +114 -23
- package/src/shared/config.ts +142 -8
- package/src/client/route-switcher.css +0 -136
- package/src/client/route-switcher.tsx +0 -277
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
|
-
|
|
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 =
|
|
450
|
-
|
|
451
|
-
|
|
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 *
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
|
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
|
|
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
|
|
67
|
-
*
|
|
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). */
|