@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
|
@@ -327,3 +327,144 @@
|
|
|
327
327
|
line-height: 16px;
|
|
328
328
|
padding: 0 8px;
|
|
329
329
|
}
|
|
330
|
+
|
|
331
|
+
/* ── model display order ────────────────────────────────────────────────── */
|
|
332
|
+
|
|
333
|
+
.mr-sort {
|
|
334
|
+
display: flex;
|
|
335
|
+
flex-direction: column;
|
|
336
|
+
gap: 8px;
|
|
337
|
+
padding: 8px 10px;
|
|
338
|
+
border: 1px solid var(--dsw-alias-border-inverted);
|
|
339
|
+
border-radius: 10px;
|
|
340
|
+
background: var(--dsw-alias-bg-module-platform);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.mr-sort-head {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
gap: 2px;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.mr-sort-title {
|
|
350
|
+
color: var(--dsw-alias-label-primary);
|
|
351
|
+
font-size: 13px;
|
|
352
|
+
line-height: 20px;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.mr-sort-desc {
|
|
356
|
+
color: var(--dsw-alias-label-tertiary);
|
|
357
|
+
font-size: 12px;
|
|
358
|
+
line-height: 18px;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.mr-sort-modes {
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
gap: 4px;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.mr-sort-mode {
|
|
368
|
+
display: flex;
|
|
369
|
+
align-items: flex-start;
|
|
370
|
+
gap: 8px;
|
|
371
|
+
border: 1px solid transparent;
|
|
372
|
+
border-radius: 8px;
|
|
373
|
+
padding: 5px 6px;
|
|
374
|
+
cursor: pointer;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.mr-sort-mode:hover {
|
|
378
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.mr-sort-mode input[type="radio"] {
|
|
382
|
+
margin-top: 2px;
|
|
383
|
+
accent-color: var(--dsw-alias-accent);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.mr-sort-mode-copy {
|
|
387
|
+
display: flex;
|
|
388
|
+
flex-direction: column;
|
|
389
|
+
gap: 1px;
|
|
390
|
+
min-width: 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.mr-sort-mode-label {
|
|
394
|
+
color: var(--dsw-alias-label-primary);
|
|
395
|
+
font-size: 13px;
|
|
396
|
+
line-height: 20px;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.mr-sort-mode-desc {
|
|
400
|
+
color: var(--dsw-alias-label-tertiary);
|
|
401
|
+
font-size: 12px;
|
|
402
|
+
line-height: 18px;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.mr-sort-custom {
|
|
406
|
+
display: flex;
|
|
407
|
+
flex-direction: column;
|
|
408
|
+
gap: 6px;
|
|
409
|
+
border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);
|
|
410
|
+
padding-top: 8px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.mr-sort-hint {
|
|
414
|
+
color: var(--dsw-alias-label-caption);
|
|
415
|
+
margin: 0;
|
|
416
|
+
font-size: 11px;
|
|
417
|
+
line-height: 16px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.mr-sort-list {
|
|
421
|
+
list-style: none;
|
|
422
|
+
margin: 0;
|
|
423
|
+
padding: 0;
|
|
424
|
+
display: flex;
|
|
425
|
+
flex-direction: column;
|
|
426
|
+
gap: 4px;
|
|
427
|
+
max-height: 280px;
|
|
428
|
+
overflow-y: auto;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.mr-sort-item {
|
|
432
|
+
display: flex;
|
|
433
|
+
align-items: center;
|
|
434
|
+
gap: 6px;
|
|
435
|
+
border: 1px solid var(--dsw-alias-border-l2, #2a3346);
|
|
436
|
+
border-radius: 8px;
|
|
437
|
+
padding: 4px 8px 4px 4px;
|
|
438
|
+
background: var(--dsw-alias-bg-layer-1, #0d1017);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.mr-sort-item-drop {
|
|
442
|
+
border-color: var(--dsw-alias-border-brand);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.mr-sort-item-busy {
|
|
446
|
+
opacity: 0.6;
|
|
447
|
+
pointer-events: none;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.mr-sort-model-name {
|
|
451
|
+
color: var(--dsw-alias-label-primary);
|
|
452
|
+
font-size: 13px;
|
|
453
|
+
line-height: 20px;
|
|
454
|
+
text-overflow: ellipsis;
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
overflow: hidden;
|
|
457
|
+
min-width: 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.mr-sort-model-id {
|
|
461
|
+
color: var(--dsw-alias-label-tertiary);
|
|
462
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
463
|
+
font-size: 11px;
|
|
464
|
+
line-height: 16px;
|
|
465
|
+
text-overflow: ellipsis;
|
|
466
|
+
white-space: nowrap;
|
|
467
|
+
overflow: hidden;
|
|
468
|
+
flex: 1;
|
|
469
|
+
text-align: right;
|
|
470
|
+
}
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* mutation controls.
|
|
17
17
|
*/
|
|
18
18
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
19
|
-
import type {
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
type ClientContext = Context
|
|
20
21
|
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
21
22
|
import { LOCALE_NS } from './locales'
|
|
22
|
-
import { api, type ModelRouterState, type RouterModelView } from './api'
|
|
23
|
+
import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
|
|
23
24
|
import './settings-card.css'
|
|
24
25
|
|
|
25
|
-
/** Client settings scope face (subset of
|
|
26
|
+
/** Client settings scope face (subset of the app client modules). */
|
|
26
27
|
export interface SettingsScopeFace {
|
|
27
28
|
getSnapshot(): {
|
|
28
29
|
status: 'loading' | 'ready' | 'unavailable'
|
|
@@ -93,6 +94,8 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
93
94
|
const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
|
|
94
95
|
const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
|
|
95
96
|
const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
|
|
97
|
+
const [sortBusy, setSortBusy] = useState(false)
|
|
98
|
+
const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
|
|
96
99
|
|
|
97
100
|
const load = useCallback(async () => {
|
|
98
101
|
setLoading(true)
|
|
@@ -177,6 +180,41 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
177
180
|
}
|
|
178
181
|
}, [state])
|
|
179
182
|
|
|
183
|
+
const changeModelSort = useCallback(async (mode: ModelSortMode) => {
|
|
184
|
+
if (state === null || state.modelSort === mode) return
|
|
185
|
+
const previous = state.modelSort
|
|
186
|
+
setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
|
|
187
|
+
setSortBusy(true)
|
|
188
|
+
try {
|
|
189
|
+
setState(await api.setModelSort(mode))
|
|
190
|
+
setError(null)
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
|
|
193
|
+
setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
194
|
+
} finally {
|
|
195
|
+
setSortBusy(false)
|
|
196
|
+
}
|
|
197
|
+
}, [state, t])
|
|
198
|
+
|
|
199
|
+
const moveModel = useCallback(async (from: number, to: number) => {
|
|
200
|
+
if (state === null || from === to) return
|
|
201
|
+
// The drag list shows the full display order (modelOrder + catalog
|
|
202
|
+
// tail); persist the whole reordered id list as the new custom order.
|
|
203
|
+
const ids = state.models.map(model => model.id)
|
|
204
|
+
const order = arrayMove(ids, from, to)
|
|
205
|
+
setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
|
|
206
|
+
setSortBusy(true)
|
|
207
|
+
try {
|
|
208
|
+
setState(await api.setModelOrder(order))
|
|
209
|
+
setError(null)
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
|
|
212
|
+
setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
213
|
+
} finally {
|
|
214
|
+
setSortBusy(false)
|
|
215
|
+
}
|
|
216
|
+
}, [state, t])
|
|
217
|
+
|
|
180
218
|
// The namespace is served by the host once the scope is ready. While it is
|
|
181
219
|
// merely loading, keep the card mounted (the tab already dispatched it);
|
|
182
220
|
// if it is unavailable (deployment without the host half), render nothing.
|
|
@@ -235,6 +273,77 @@ export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps)
|
|
|
235
273
|
<span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
|
|
236
274
|
</span>
|
|
237
275
|
</label>
|
|
276
|
+
<div className="mr-sort">
|
|
277
|
+
<div className="mr-sort-head">
|
|
278
|
+
<span className="mr-sort-title">{t('sortTitle')}</span>
|
|
279
|
+
<span className="mr-sort-desc">{t('sortDescription')}</span>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
|
|
282
|
+
{(['custom', 'name', 'recent'] as const).map(mode => (
|
|
283
|
+
<label key={mode} className="mr-sort-mode">
|
|
284
|
+
<input
|
|
285
|
+
type="radio"
|
|
286
|
+
name="mr-model-sort"
|
|
287
|
+
checked={state?.modelSort === mode}
|
|
288
|
+
disabled={loading || sortBusy || !writable}
|
|
289
|
+
onChange={() => void changeModelSort(mode)}
|
|
290
|
+
/>
|
|
291
|
+
<span className="mr-sort-mode-copy">
|
|
292
|
+
<span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
293
|
+
<span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
294
|
+
</span>
|
|
295
|
+
</label>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
{state?.modelSort === 'custom' && (
|
|
299
|
+
<div className="mr-sort-custom">
|
|
300
|
+
<p className="mr-sort-hint">{t('sortCustomHint')}</p>
|
|
301
|
+
<ol className="mr-sort-list">
|
|
302
|
+
{models.map((model, index) => {
|
|
303
|
+
const dropTarget = modelDrag !== null && modelDrag.index !== index
|
|
304
|
+
return (
|
|
305
|
+
<li
|
|
306
|
+
key={model.id}
|
|
307
|
+
className={[
|
|
308
|
+
'mr-sort-item',
|
|
309
|
+
dropTarget ? 'mr-sort-item-drop' : '',
|
|
310
|
+
sortBusy ? 'mr-sort-item-busy' : '',
|
|
311
|
+
].filter(Boolean).join(' ')}
|
|
312
|
+
draggable={!sortBusy && writable}
|
|
313
|
+
onDragStart={(event) => {
|
|
314
|
+
setModelDrag({ index })
|
|
315
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
316
|
+
try {
|
|
317
|
+
event.dataTransfer.setData('text/plain', model.id)
|
|
318
|
+
} catch {
|
|
319
|
+
// drag data is cosmetic for our own handler
|
|
320
|
+
}
|
|
321
|
+
}}
|
|
322
|
+
onDragOver={(event) => {
|
|
323
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
324
|
+
event.preventDefault()
|
|
325
|
+
event.dataTransfer.dropEffect = 'move'
|
|
326
|
+
}
|
|
327
|
+
}}
|
|
328
|
+
onDrop={(event) => {
|
|
329
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
330
|
+
event.preventDefault()
|
|
331
|
+
moveModel(modelDrag.index, index)
|
|
332
|
+
}
|
|
333
|
+
setModelDrag(null)
|
|
334
|
+
}}
|
|
335
|
+
onDragEnd={() => setModelDrag(null)}
|
|
336
|
+
>
|
|
337
|
+
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
338
|
+
<span className="mr-sort-model-name">{model.name}</span>
|
|
339
|
+
<span className="mr-sort-model-id">{model.id}</span>
|
|
340
|
+
</li>
|
|
341
|
+
)
|
|
342
|
+
})}
|
|
343
|
+
</ol>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
238
347
|
<div className="mr-toolbar">
|
|
239
348
|
<span className="mr-count">{t('modelsCount', { count: models.length })}</span>
|
|
240
349
|
<button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
|
package/src/server/adapter.ts
CHANGED
|
@@ -13,12 +13,14 @@ import type {
|
|
|
13
13
|
LlmModelInfo,
|
|
14
14
|
LlmProviderInfo,
|
|
15
15
|
LlmResolvedModelInfo,
|
|
16
|
+
PreparedAdapterCall,
|
|
16
17
|
StreamChunk,
|
|
17
18
|
} from '@deepseek-ai/dsh-llm'
|
|
18
19
|
import {
|
|
19
20
|
displayProvider,
|
|
20
21
|
resolveActive,
|
|
21
22
|
ROUTER_PROVIDER_ID,
|
|
23
|
+
sortModelsForList,
|
|
22
24
|
stripModelIdPrefix,
|
|
23
25
|
type MergedModel,
|
|
24
26
|
type RouterConfigShape,
|
|
@@ -34,6 +36,8 @@ export interface RouterFacts {
|
|
|
34
36
|
providerNames(): ReadonlyMap<string, string>
|
|
35
37
|
/** The LLM runtime to delegate to. */
|
|
36
38
|
llm(): LlmRuntime
|
|
39
|
+
/** Record one model request for the recent-use ordering (fire-and-forget). */
|
|
40
|
+
noteModelUsed(modelId: string): void
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** One resolved route for a model id. */
|
|
@@ -76,6 +80,27 @@ export function entryRawId(merged: MergedModel, providerId: string): string {
|
|
|
76
80
|
return merged.providers.find(provider => provider.provider === providerId)?.id ?? merged.id
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
/** One validated route plus the merged model it was resolved against. */
|
|
84
|
+
export interface BoundRoute {
|
|
85
|
+
route: ResolvedRoute
|
|
86
|
+
merged: MergedModel
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function unroutable(model: string): LlmError {
|
|
90
|
+
return new LlmError(`model-router: no provider serves model "${model}"`, 'NO_ADAPTER')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve one model id to its active provider and merged model, rejecting
|
|
95
|
+
* unadvertised or currently unroutable ids before any provider I/O.
|
|
96
|
+
*/
|
|
97
|
+
export function bindRoute(facts: RouterFacts, modelId: string): BoundRoute {
|
|
98
|
+
const merged = mergedFor(facts, modelId)
|
|
99
|
+
const route = merged === null ? null : routeFor(facts, modelId)
|
|
100
|
+
if (merged === null || route === null) throw unroutable(modelId)
|
|
101
|
+
return { route, merged }
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
export class ModelRouterAdapter extends LlmAdapter {
|
|
80
105
|
constructor(private readonly facts: RouterFacts) {
|
|
81
106
|
super()
|
|
@@ -86,10 +111,11 @@ export class ModelRouterAdapter extends LlmAdapter {
|
|
|
86
111
|
}
|
|
87
112
|
|
|
88
113
|
async listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
|
114
|
+
const config = this.facts.config()
|
|
89
115
|
const { models } = this.facts.catalog()
|
|
90
116
|
const names = this.facts.providerNames()
|
|
91
117
|
const out: LlmModelInfo[] = []
|
|
92
|
-
for (const merged of models) {
|
|
118
|
+
for (const merged of sortModelsForList(models, config)) {
|
|
93
119
|
const route = routeFor(this.facts, merged.id)
|
|
94
120
|
const display = displayProvider(merged, route?.provider)
|
|
95
121
|
if (display === null) continue
|
|
@@ -104,14 +130,35 @@ export class ModelRouterAdapter extends LlmAdapter {
|
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
async resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
|
|
107
|
-
const route =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
133
|
+
const { route, merged } = bindRoute(this.facts, model)
|
|
134
|
+
return this.resolveRoutedIdentity(route, merged, signal)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Bind the route generation at preparation time: a provider switch or
|
|
139
|
+
* catalog refresh between preparation and dispatch cannot combine one
|
|
140
|
+
* generation's metadata with another's endpoint, matching the host's
|
|
141
|
+
* one-generation dispatch contract.
|
|
142
|
+
*/
|
|
143
|
+
async prepareCall(_provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall> {
|
|
144
|
+
const { route, merged } = bindRoute(this.facts, model)
|
|
145
|
+
return {
|
|
146
|
+
model: await this.resolveRoutedIdentity(route, merged, signal),
|
|
147
|
+
stream: (options: GenerateOptions) => this.streamRouted(options, route, merged),
|
|
111
148
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
152
|
+
const { route, merged } = bindRoute(this.facts, options.model)
|
|
153
|
+
yield* this.streamRouted(options, route, merged)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Delegate with the provider's raw local id (it may carry a vendor/
|
|
158
|
+
* prefix the real provider needs), then normalize the resolved identity
|
|
159
|
+
* back to the merged display id so sessions/metering never leak prefixes.
|
|
160
|
+
*/
|
|
161
|
+
private async resolveRoutedIdentity(route: ResolvedRoute, merged: MergedModel, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
|
|
115
162
|
const resolved = await this.facts.llm().resolveModelInfo(route.provider, entryRawId(merged, route.provider), signal)
|
|
116
163
|
const display = displayProvider(merged, route.provider)
|
|
117
164
|
return {
|
|
@@ -122,11 +169,13 @@ export class ModelRouterAdapter extends LlmAdapter {
|
|
|
122
169
|
}
|
|
123
170
|
}
|
|
124
171
|
|
|
125
|
-
async *
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
private async *streamRouted(options: GenerateOptions, route: ResolvedRoute, merged: MergedModel): AsyncIterable<StreamChunk> {
|
|
173
|
+
// Record the use for the recent-use model ordering; never blocks or
|
|
174
|
+
// fails the request (the service debounces persistence).
|
|
175
|
+
try {
|
|
176
|
+
this.facts.noteModelUsed(merged.id)
|
|
177
|
+
} catch {
|
|
178
|
+
// advisory only
|
|
130
179
|
}
|
|
131
180
|
// In-process delegation: the inner call resolves the real adapter and
|
|
132
181
|
// runs its full pipeline (retry, waterfall, replay-state stripping). The
|
package/src/server/rpc.ts
CHANGED
|
@@ -150,6 +150,22 @@ export function registerApiRoutes(ctx: Context, service: ModelRouterService): ()
|
|
|
150
150
|
return service.state()
|
|
151
151
|
})
|
|
152
152
|
|
|
153
|
+
route('set-model-sort', async (p) => {
|
|
154
|
+
if (p.mode !== 'custom' && p.mode !== 'name' && p.mode !== 'recent') {
|
|
155
|
+
throw new Error('mode 必须是 custom、name 或 recent')
|
|
156
|
+
}
|
|
157
|
+
await service.setModelSort(p.mode)
|
|
158
|
+
return service.state()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
route('set-model-order', async (p) => {
|
|
162
|
+
if (!Array.isArray(p.order) || !p.order.every((id): id is string => typeof id === 'string')) {
|
|
163
|
+
throw new Error('order 必须是模型 id 数组')
|
|
164
|
+
}
|
|
165
|
+
await service.setModelOrder(p.order)
|
|
166
|
+
return service.state()
|
|
167
|
+
})
|
|
168
|
+
|
|
153
169
|
return () => {
|
|
154
170
|
for (const dispose of disposers) dispose()
|
|
155
171
|
}
|
package/src/server/service.ts
CHANGED
|
@@ -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 {
|
|
8
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
9
9
|
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
import {
|
|
11
11
|
initialConfigFor,
|
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
resolveActive,
|
|
17
17
|
setActive,
|
|
18
18
|
setOrder,
|
|
19
|
+
sortModelsForList,
|
|
20
|
+
type ModelSortMode,
|
|
19
21
|
type RouterConfigShape,
|
|
20
22
|
} from '../shared/config.js'
|
|
21
23
|
import { buildCatalog, type RouterCatalog } from './catalog.js'
|
|
22
24
|
import { ModelRouterAdapter, type RouterFacts } from './adapter.js'
|
|
23
25
|
|
|
24
|
-
export const NS =
|
|
26
|
+
export const NS = ROUTER_SETTINGS_NS
|
|
25
27
|
|
|
26
28
|
/** Settings document schema: one entry per model id. */
|
|
27
29
|
export const RouterConfigSchema: z<RouterConfigShape> = z.object({
|
|
@@ -33,16 +35,30 @@ export const RouterConfigSchema: z<RouterConfigShape> = z.object({
|
|
|
33
35
|
// Schemastery object keys are input-optional, so absent is valid here.
|
|
34
36
|
showQuickSwitch: z.boolean(),
|
|
35
37
|
ignoreModelIdPrefix: z.boolean(),
|
|
38
|
+
modelSort: z.union([z.const('custom'), z.const('name'), z.const('recent')]),
|
|
39
|
+
modelOrder: z.array(z.string()),
|
|
40
|
+
recentlyUsed: z.dict(z.number()),
|
|
36
41
|
})
|
|
37
42
|
|
|
38
|
-
const EMPTY_CONFIG: RouterConfigShape = {
|
|
43
|
+
const EMPTY_CONFIG: RouterConfigShape = {
|
|
44
|
+
models: {},
|
|
45
|
+
showQuickSwitch: true,
|
|
46
|
+
ignoreModelIdPrefix: true,
|
|
47
|
+
modelSort: 'custom',
|
|
48
|
+
modelOrder: [],
|
|
49
|
+
recentlyUsed: {},
|
|
50
|
+
}
|
|
39
51
|
|
|
40
52
|
/** The settings-page state view served by the API. */
|
|
41
53
|
export interface ModelRouterState {
|
|
42
|
-
/** Whether the composer
|
|
54
|
+
/** Whether provider switching is shown inside the composer model picker menu. */
|
|
43
55
|
showQuickSwitch: boolean
|
|
44
56
|
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
45
57
|
ignoreModelIdPrefix: boolean
|
|
58
|
+
/** The model-list display order mode (custom | name | recent). */
|
|
59
|
+
modelSort: ModelSortMode
|
|
60
|
+
/** Explicit model id order for custom mode (display order). */
|
|
61
|
+
modelOrder: string[]
|
|
46
62
|
providers: Array<{ id: string; name: string; credentialConfigured: boolean }>
|
|
47
63
|
models: Array<{
|
|
48
64
|
id: string
|
|
@@ -73,6 +89,12 @@ export class ModelRouterService implements RouterFacts {
|
|
|
73
89
|
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
74
90
|
private readonly catalogTtlMs = 30_000
|
|
75
91
|
|
|
92
|
+
/** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
|
|
93
|
+
private readonly usageFlushMs = 1_500
|
|
94
|
+
private usageDebounce: ReturnType<typeof setTimeout> | null = null
|
|
95
|
+
private pendingUsage: Record<string, number> = {}
|
|
96
|
+
private usageFlushRunning = false
|
|
97
|
+
|
|
76
98
|
constructor(ctx: Context) {
|
|
77
99
|
this.ctx = ctx
|
|
78
100
|
}
|
|
@@ -99,6 +121,14 @@ export class ModelRouterService implements RouterFacts {
|
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
stop(): void {
|
|
124
|
+
// Flush any pending recent-use writes before the scope is torn down.
|
|
125
|
+
if (this.usageDebounce !== null) {
|
|
126
|
+
clearTimeout(this.usageDebounce)
|
|
127
|
+
this.usageDebounce = null
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(this.pendingUsage).length > 0) {
|
|
130
|
+
void this.flushUsage()
|
|
131
|
+
}
|
|
102
132
|
this.registration?.()
|
|
103
133
|
this.registration = undefined
|
|
104
134
|
}
|
|
@@ -126,6 +156,45 @@ export class ModelRouterService implements RouterFacts {
|
|
|
126
156
|
return this.ctx.llm
|
|
127
157
|
}
|
|
128
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Record one model request for the `recent` display order. Fire-and-forget
|
|
161
|
+
* (the adapter never awaits it): timestamps accumulate in memory and are
|
|
162
|
+
* persisted debounced, so a burst of requests coalesces into one settings
|
|
163
|
+
* write.
|
|
164
|
+
*/
|
|
165
|
+
noteModelUsed(modelId: string): void {
|
|
166
|
+
if (this.scope === undefined) return
|
|
167
|
+
this.pendingUsage[modelId] = Date.now()
|
|
168
|
+
if (this.usageDebounce !== null) return
|
|
169
|
+
this.usageDebounce = setTimeout(() => {
|
|
170
|
+
this.usageDebounce = null
|
|
171
|
+
void this.flushUsage()
|
|
172
|
+
}, this.usageFlushMs)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Persist accumulated usage timestamps (merging with any concurrent writes). */
|
|
176
|
+
private async flushUsage(): Promise<void> {
|
|
177
|
+
if (this.usageFlushRunning) return
|
|
178
|
+
this.usageFlushRunning = true
|
|
179
|
+
const pending = this.pendingUsage
|
|
180
|
+
this.pendingUsage = {}
|
|
181
|
+
try {
|
|
182
|
+
if (this.scope === undefined || Object.keys(pending).length === 0) return
|
|
183
|
+
const current = this.config()
|
|
184
|
+
await this.scope.replace({
|
|
185
|
+
...current,
|
|
186
|
+
recentlyUsed: { ...current.recentlyUsed, ...pending },
|
|
187
|
+
})
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Re-queue the failed batch so a transient write failure does not lose
|
|
190
|
+
// usage history; the next request retries it.
|
|
191
|
+
this.pendingUsage = { ...pending, ...this.pendingUsage }
|
|
192
|
+
this.ctx.logger.warn(`model-router: usage persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
193
|
+
} finally {
|
|
194
|
+
this.usageFlushRunning = false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
129
198
|
// ── catalog lifecycle ────────────────────────────────────────────────────
|
|
130
199
|
|
|
131
200
|
/**
|
|
@@ -166,13 +235,13 @@ export class ModelRouterService implements RouterFacts {
|
|
|
166
235
|
const catalog = await this.refreshCatalog(true)
|
|
167
236
|
if (this.scope === undefined) return
|
|
168
237
|
const current = this.config()
|
|
169
|
-
const { models, changed } = reconcileConfig(current, catalog.models)
|
|
238
|
+
const { models, modelOrder, changed } = reconcileConfig(current, catalog.models)
|
|
170
239
|
if (!changed) return
|
|
171
240
|
try {
|
|
172
241
|
await this.scope.replace({
|
|
242
|
+
...current,
|
|
173
243
|
models,
|
|
174
|
-
|
|
175
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
244
|
+
...(modelOrder === undefined ? {} : { modelOrder }),
|
|
176
245
|
})
|
|
177
246
|
} catch (error) {
|
|
178
247
|
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -197,12 +266,14 @@ export class ModelRouterService implements RouterFacts {
|
|
|
197
266
|
return {
|
|
198
267
|
showQuickSwitch: config.showQuickSwitch,
|
|
199
268
|
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
269
|
+
modelSort: config.modelSort,
|
|
270
|
+
modelOrder: config.modelOrder,
|
|
200
271
|
providers: catalog.providers.map(provider => ({
|
|
201
272
|
id: provider.id,
|
|
202
273
|
name: names.get(provider.id) ?? provider.id,
|
|
203
274
|
credentialConfigured: creds.get(provider.id) ?? true,
|
|
204
275
|
})),
|
|
205
|
-
models: catalog.models.map((model) => {
|
|
276
|
+
models: sortModelsForList(catalog.models, config).map((model) => {
|
|
206
277
|
const ids = model.providers.map(provider => provider.provider)
|
|
207
278
|
const stored = config.models[model.id] ?? initialConfigFor(ids)
|
|
208
279
|
const active = resolveActive(config, model.id, ids)
|
|
@@ -253,11 +324,7 @@ export class ModelRouterService implements RouterFacts {
|
|
|
253
324
|
}
|
|
254
325
|
const current = this.config()
|
|
255
326
|
if (current.showQuickSwitch === value) return
|
|
256
|
-
await this.scope.replace({
|
|
257
|
-
models: current.models,
|
|
258
|
-
showQuickSwitch: value,
|
|
259
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
260
|
-
})
|
|
327
|
+
await this.scope.replace({ ...current, showQuickSwitch: value })
|
|
261
328
|
}
|
|
262
329
|
|
|
263
330
|
/**
|
|
@@ -272,23 +339,47 @@ export class ModelRouterService implements RouterFacts {
|
|
|
272
339
|
}
|
|
273
340
|
const current = this.config()
|
|
274
341
|
if (current.ignoreModelIdPrefix === value) return
|
|
275
|
-
await this.scope.replace({
|
|
276
|
-
models: current.models,
|
|
277
|
-
showQuickSwitch: current.showQuickSwitch,
|
|
278
|
-
ignoreModelIdPrefix: value,
|
|
279
|
-
})
|
|
342
|
+
await this.scope.replace({ ...current, ignoreModelIdPrefix: value })
|
|
280
343
|
await this.refreshAndReconcile()
|
|
281
344
|
}
|
|
282
345
|
|
|
346
|
+
/** Switch the model-list display order mode (global, persisted). */
|
|
347
|
+
async setModelSort(mode: ModelSortMode): Promise<void> {
|
|
348
|
+
if (this.scope === undefined) {
|
|
349
|
+
throw new Error('model-router: settings service is not available in this profile')
|
|
350
|
+
}
|
|
351
|
+
const current = this.config()
|
|
352
|
+
if (current.modelSort === mode) return
|
|
353
|
+
await this.scope.replace({ ...current, modelSort: mode })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Replace the explicit custom model order (global, persisted). Vanished
|
|
358
|
+
* ids are pruned and missing catalog models appended, so the stored order
|
|
359
|
+
* always covers the live catalog.
|
|
360
|
+
*/
|
|
361
|
+
async setModelOrder(order: string[]): Promise<void> {
|
|
362
|
+
if (this.scope === undefined) {
|
|
363
|
+
throw new Error('model-router: settings service is not available in this profile')
|
|
364
|
+
}
|
|
365
|
+
await this.refreshCatalog()
|
|
366
|
+
const ids = new Set(this.cached.models.map(model => model.id))
|
|
367
|
+
const clean = order.filter(id => ids.has(id))
|
|
368
|
+
for (const model of this.cached.models) {
|
|
369
|
+
if (!clean.includes(model.id)) clean.push(model.id)
|
|
370
|
+
}
|
|
371
|
+
const current = this.config()
|
|
372
|
+
const sameOrder = clean.length === current.modelOrder.length
|
|
373
|
+
&& clean.every((id, at) => id === current.modelOrder[at])
|
|
374
|
+
if (sameOrder) return
|
|
375
|
+
await this.scope.replace({ ...current, modelOrder: clean })
|
|
376
|
+
}
|
|
377
|
+
|
|
283
378
|
private async persist(models: Record<string, { order: string[]; active: string }>): Promise<void> {
|
|
284
379
|
if (this.scope === undefined) {
|
|
285
380
|
throw new Error('model-router: settings service is not available in this profile')
|
|
286
381
|
}
|
|
287
382
|
const current = this.config()
|
|
288
|
-
await this.scope.replace({
|
|
289
|
-
models,
|
|
290
|
-
showQuickSwitch: current.showQuickSwitch,
|
|
291
|
-
ignoreModelIdPrefix: current.ignoreModelIdPrefix,
|
|
292
|
-
})
|
|
383
|
+
await this.scope.replace({ ...current, models })
|
|
293
384
|
}
|
|
294
385
|
}
|