@fonlan/dsh-model-router 0.3.0 → 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/README.md +17 -10
- package/lib/client.js +844 -239
- package/lib/client.js.map +1 -1
- package/lib/index.js +257 -30
- package/lib/types/server/adapter.d.ts +27 -1
- package/lib/types/server/service.d.ts +45 -5
- package/lib/types/shared/config.d.ts +54 -2
- package/package.json +33 -29
- package/src/client/api.ts +11 -1
- package/src/client/index.tsx +39 -16
- package/src/client/locales.ts +64 -14
- package/src/client/model-seat.css +289 -0
- package/src/client/model-seat.tsx +623 -0
- package/src/client/settings-card.css +470 -0
- package/src/client/settings-card.tsx +468 -0
- package/src/server/adapter.ts +62 -13
- package/src/server/rpc.ts +16 -0
- package/src/server/service.ts +135 -25
- package/src/shared/config.ts +142 -8
- package/src/client/route-switcher.css +0 -136
- package/src/client/route-switcher.tsx +0 -252
- package/src/client/settings-section.css +0 -245
- package/src/client/settings-section.tsx +0 -293
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
|
-
|
|
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 =
|
|
450
|
-
|
|
451
|
-
|
|
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 *
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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;
|
|
@@ -496,8 +619,16 @@ var ModelRouterService = class {
|
|
|
496
619
|
credentialConfigured: /* @__PURE__ */ new Map()
|
|
497
620
|
};
|
|
498
621
|
inflight = null;
|
|
622
|
+
cachedAt = 0;
|
|
499
623
|
scope;
|
|
500
624
|
registration;
|
|
625
|
+
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
626
|
+
catalogTtlMs = 3e4;
|
|
627
|
+
/** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
|
|
628
|
+
usageFlushMs = 1500;
|
|
629
|
+
usageDebounce = null;
|
|
630
|
+
pendingUsage = {};
|
|
631
|
+
usageFlushRunning = false;
|
|
501
632
|
constructor(ctx) {
|
|
502
633
|
this.ctx = ctx;
|
|
503
634
|
}
|
|
@@ -514,6 +645,11 @@ var ModelRouterService = class {
|
|
|
514
645
|
this.refreshAndReconcile();
|
|
515
646
|
}
|
|
516
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();
|
|
517
653
|
this.registration?.();
|
|
518
654
|
this.registration = void 0;
|
|
519
655
|
}
|
|
@@ -537,11 +673,65 @@ var ModelRouterService = class {
|
|
|
537
673
|
llm() {
|
|
538
674
|
return this.ctx.llm;
|
|
539
675
|
}
|
|
540
|
-
/**
|
|
541
|
-
|
|
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
|
+
/**
|
|
718
|
+
* Return the live catalog, rebuilding it only when it is stale (older than
|
|
719
|
+
* `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
|
|
720
|
+
* build; a failed build keeps the last good catalog.
|
|
721
|
+
*
|
|
722
|
+
* The TTL is what keeps the composer quick-switcher snappy: every
|
|
723
|
+
* `state()` RPC goes through here, and without a cache each one rebuilt the
|
|
724
|
+
* catalog — including per-provider `listModels` network calls — which is
|
|
725
|
+
* what made the button gray out (routerLoading) for the whole duration.
|
|
726
|
+
* Topology events (`llm/adapters-updated`) and explicit user refreshes pass
|
|
727
|
+
* `force` so the cache never hides real changes.
|
|
728
|
+
*/
|
|
729
|
+
refreshCatalog(force = false) {
|
|
730
|
+
if (!force && this.inflight === null && Date.now() - this.cachedAt < this.catalogTtlMs) return Promise.resolve(this.cached);
|
|
542
731
|
if (this.inflight !== null) return this.inflight;
|
|
543
732
|
this.inflight = buildCatalog(this.ctx, this.config().ignoreModelIdPrefix).then((catalog) => {
|
|
544
733
|
this.cached = catalog;
|
|
734
|
+
this.cachedAt = Date.now();
|
|
545
735
|
return catalog;
|
|
546
736
|
}).catch((error) => {
|
|
547
737
|
this.ctx.logger.warn(`model-router: catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -551,18 +741,18 @@ var ModelRouterService = class {
|
|
|
551
741
|
});
|
|
552
742
|
return this.inflight;
|
|
553
743
|
}
|
|
554
|
-
/** Refresh, then persist any config drift (migration, new/vanished models). */
|
|
744
|
+
/** Refresh (forced), then persist any config drift (migration, new/vanished models). */
|
|
555
745
|
async refreshAndReconcile() {
|
|
556
|
-
const catalog = await this.refreshCatalog();
|
|
746
|
+
const catalog = await this.refreshCatalog(true);
|
|
557
747
|
if (this.scope === void 0) return;
|
|
558
748
|
const current = this.config();
|
|
559
|
-
const { models, changed } = reconcileConfig(current, catalog.models);
|
|
749
|
+
const { models, modelOrder, changed } = reconcileConfig(current, catalog.models);
|
|
560
750
|
if (!changed) return;
|
|
561
751
|
try {
|
|
562
752
|
await this.scope.replace({
|
|
753
|
+
...current,
|
|
563
754
|
models,
|
|
564
|
-
|
|
565
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
755
|
+
...modelOrder === void 0 ? {} : { modelOrder }
|
|
566
756
|
});
|
|
567
757
|
} catch (error) {
|
|
568
758
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -583,12 +773,14 @@ var ModelRouterService = class {
|
|
|
583
773
|
return {
|
|
584
774
|
showQuickSwitch: config.showQuickSwitch,
|
|
585
775
|
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
776
|
+
modelSort: config.modelSort,
|
|
777
|
+
modelOrder: config.modelOrder,
|
|
586
778
|
providers: catalog.providers.map((provider) => ({
|
|
587
779
|
id: provider.id,
|
|
588
780
|
name: names.get(provider.id) ?? provider.id,
|
|
589
781
|
credentialConfigured: creds.get(provider.id) ?? true
|
|
590
782
|
})),
|
|
591
|
-
models: catalog.models.map((model) => {
|
|
783
|
+
models: sortModelsForList(catalog.models, config).map((model) => {
|
|
592
784
|
const ids = model.providers.map((provider) => provider.provider);
|
|
593
785
|
const stored = config.models[model.id] ?? initialConfigFor(ids);
|
|
594
786
|
const active = resolveActive(config, model.id, ids);
|
|
@@ -631,9 +823,8 @@ var ModelRouterService = class {
|
|
|
631
823
|
const current = this.config();
|
|
632
824
|
if (current.showQuickSwitch === value) return;
|
|
633
825
|
await this.scope.replace({
|
|
634
|
-
|
|
635
|
-
showQuickSwitch: value
|
|
636
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
826
|
+
...current,
|
|
827
|
+
showQuickSwitch: value
|
|
637
828
|
});
|
|
638
829
|
}
|
|
639
830
|
/**
|
|
@@ -647,19 +838,45 @@ var ModelRouterService = class {
|
|
|
647
838
|
const current = this.config();
|
|
648
839
|
if (current.ignoreModelIdPrefix === value) return;
|
|
649
840
|
await this.scope.replace({
|
|
650
|
-
|
|
651
|
-
showQuickSwitch: current.showQuickSwitch,
|
|
841
|
+
...current,
|
|
652
842
|
ignoreModelIdPrefix: value
|
|
653
843
|
});
|
|
654
844
|
await this.refreshAndReconcile();
|
|
655
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
|
+
}
|
|
656
874
|
async persist(models) {
|
|
657
875
|
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
658
876
|
const current = this.config();
|
|
659
877
|
await this.scope.replace({
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix
|
|
878
|
+
...current,
|
|
879
|
+
models
|
|
663
880
|
});
|
|
664
881
|
}
|
|
665
882
|
};
|
|
@@ -785,6 +1002,16 @@ function registerApiRoutes(ctx, service) {
|
|
|
785
1002
|
await service.setIgnoreModelIdPrefix(p.value);
|
|
786
1003
|
return service.state();
|
|
787
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
|
+
});
|
|
788
1015
|
return () => {
|
|
789
1016
|
for (const dispose of disposers) dispose();
|
|
790
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
|
|
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;
|
|
@@ -41,8 +45,16 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
41
45
|
private readonly ctx;
|
|
42
46
|
private cached;
|
|
43
47
|
private inflight;
|
|
48
|
+
private cachedAt;
|
|
44
49
|
private scope;
|
|
45
50
|
private registration;
|
|
51
|
+
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
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;
|
|
46
58
|
constructor(ctx: Context);
|
|
47
59
|
/** Register settings + adapter and wire topology listeners. */
|
|
48
60
|
start(): void;
|
|
@@ -54,9 +66,29 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
54
66
|
config(): RouterConfigShape;
|
|
55
67
|
providerNames(): ReadonlyMap<string, string>;
|
|
56
68
|
llm(): import("@deepseek-ai/dsh-llm").LlmRuntime;
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
78
|
+
/**
|
|
79
|
+
* Return the live catalog, rebuilding it only when it is stale (older than
|
|
80
|
+
* `catalogTtlMs`) or `force` is set. Concurrent callers share one in-flight
|
|
81
|
+
* build; a failed build keeps the last good catalog.
|
|
82
|
+
*
|
|
83
|
+
* The TTL is what keeps the composer quick-switcher snappy: every
|
|
84
|
+
* `state()` RPC goes through here, and without a cache each one rebuilt the
|
|
85
|
+
* catalog — including per-provider `listModels` network calls — which is
|
|
86
|
+
* what made the button gray out (routerLoading) for the whole duration.
|
|
87
|
+
* Topology events (`llm/adapters-updated`) and explicit user refreshes pass
|
|
88
|
+
* `force` so the cache never hides real changes.
|
|
89
|
+
*/
|
|
90
|
+
refreshCatalog(force?: boolean): Promise<RouterCatalog>;
|
|
91
|
+
/** Refresh (forced), then persist any config drift (migration, new/vanished models). */
|
|
60
92
|
refreshAndReconcile(): Promise<void>;
|
|
61
93
|
/** Effective active provider for one model id (null when unroutable). */
|
|
62
94
|
activeFor(modelId: string): string | null;
|
|
@@ -75,5 +107,13 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
75
107
|
* turning on; split models re-initialize when turning off).
|
|
76
108
|
*/
|
|
77
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>;
|
|
78
118
|
private persist;
|
|
79
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). */
|