@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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Model Router Settings Card (设置 → 插件 → 插件配置 → 模型路由).
|
|
3
|
+
*
|
|
4
|
+
* Renders its own expandable chrome aligned with the built-in plugin cards
|
|
5
|
+
* (external plugins cannot import PluginCard): a header naming the plugin and
|
|
6
|
+
* what its settings govern, disclosing the router controls in place. All
|
|
7
|
+
* reads/mutations go through the plugin's fenced API; the server persists
|
|
8
|
+
* through the `model-router` settings namespace, so a switch here is live for
|
|
9
|
+
* the next request globally.
|
|
10
|
+
*
|
|
11
|
+
* The bound settings scope (the same `model-router` namespace the host half
|
|
12
|
+
* registers) supplies the card's dispatch state: while the namespace is merely
|
|
13
|
+
* loading the card stays mounted, and when it is unavailable (deployment
|
|
14
|
+
* without the host half) nothing renders — matching the built-in cards. A
|
|
15
|
+
* read-only deployment shows the built-in card's banner and disables the
|
|
16
|
+
* mutation controls.
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
19
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
20
|
+
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
21
|
+
import { LOCALE_NS } from './locales'
|
|
22
|
+
import { api, type ModelRouterState, type ModelSortMode, type RouterModelView } from './api'
|
|
23
|
+
import './settings-card.css'
|
|
24
|
+
|
|
25
|
+
/** Client settings scope face (subset of @deepseek-ai/dsh-client-runtime). */
|
|
26
|
+
export interface SettingsScopeFace {
|
|
27
|
+
getSnapshot(): {
|
|
28
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
29
|
+
writable: boolean
|
|
30
|
+
}
|
|
31
|
+
subscribe(listener: () => void): () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SettingsCardProps {
|
|
35
|
+
/** The bound `model-router` settings scope (from the slot entry's inject face). */
|
|
36
|
+
scope: SettingsScopeFace
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
|
|
40
|
+
const next = [...list]
|
|
41
|
+
const [item] = next.splice(from, 1)
|
|
42
|
+
next.splice(to, 0, item)
|
|
43
|
+
return next
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useLocaleRevision(ctx: ClientContext): number {
|
|
47
|
+
const subscribe = useCallback(
|
|
48
|
+
(onChange: () => void) => {
|
|
49
|
+
try {
|
|
50
|
+
return ctx.locale.subscribe(onChange)
|
|
51
|
+
} catch {
|
|
52
|
+
return () => {}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[ctx],
|
|
56
|
+
)
|
|
57
|
+
const getSnapshot = useCallback(() => {
|
|
58
|
+
try {
|
|
59
|
+
return ctx.locale.getLocale().revision
|
|
60
|
+
} catch {
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
}, [ctx])
|
|
64
|
+
return useSyncExternalStore(subscribe, getSnapshot)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function makeSettingsCard(ctx: ClientContext): (props: SettingsCardProps) => JSX.Element | null {
|
|
68
|
+
// Bound translation is namespace-typed; the card's props use the plain
|
|
69
|
+
// Translate face (string keys), which the dict satisfies structurally.
|
|
70
|
+
const t: Translate = (() => {
|
|
71
|
+
try {
|
|
72
|
+
return ctx.locale.bind(LOCALE_NS) as unknown as Translate
|
|
73
|
+
} catch {
|
|
74
|
+
return (key: string) => key
|
|
75
|
+
}
|
|
76
|
+
})()
|
|
77
|
+
|
|
78
|
+
return function ModelRouterSettingsCard(props: SettingsCardProps): JSX.Element | null {
|
|
79
|
+
const { scope } = props
|
|
80
|
+
// Bind the methods: React invokes getSnapshot/subscribe as bare functions,
|
|
81
|
+
// and SettingsScopeController's methods depend on `this`.
|
|
82
|
+
const snapshot = useSyncExternalStore(
|
|
83
|
+
(listener) => scope.subscribe(listener),
|
|
84
|
+
() => scope.getSnapshot(),
|
|
85
|
+
)
|
|
86
|
+
useLocaleRevision(ctx)
|
|
87
|
+
// Card-local disclosure: collapsed by default, like the built-in plugin cards.
|
|
88
|
+
const [open, setOpen] = useState(false)
|
|
89
|
+
const [state, setState] = useState<ModelRouterState | null>(null)
|
|
90
|
+
const [error, setError] = useState<string | null>(null)
|
|
91
|
+
const [loading, setLoading] = useState(true)
|
|
92
|
+
const [busyModel, setBusyModel] = useState<string | null>(null)
|
|
93
|
+
const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
|
|
94
|
+
const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
|
|
95
|
+
const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
|
|
96
|
+
const [sortBusy, setSortBusy] = useState(false)
|
|
97
|
+
const [modelDrag, setModelDrag] = useState<{ index: number } | null>(null)
|
|
98
|
+
|
|
99
|
+
const load = useCallback(async () => {
|
|
100
|
+
setLoading(true)
|
|
101
|
+
try {
|
|
102
|
+
setState(await api.state())
|
|
103
|
+
setError(null)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
106
|
+
} finally {
|
|
107
|
+
setLoading(false)
|
|
108
|
+
}
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
void load()
|
|
113
|
+
}, [load])
|
|
114
|
+
|
|
115
|
+
const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
|
|
116
|
+
if (model.active === providerId) return
|
|
117
|
+
setBusyModel(model.id)
|
|
118
|
+
try {
|
|
119
|
+
setState(await api.switchActive(model.id, providerId))
|
|
120
|
+
setError(null)
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
123
|
+
} finally {
|
|
124
|
+
setBusyModel(null)
|
|
125
|
+
}
|
|
126
|
+
}, [t])
|
|
127
|
+
|
|
128
|
+
const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
|
|
129
|
+
if (from === to) return
|
|
130
|
+
const order = arrayMove(model.order, from, to)
|
|
131
|
+
setState(prev => prev === null ? prev : {
|
|
132
|
+
...prev,
|
|
133
|
+
models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
|
|
134
|
+
})
|
|
135
|
+
setBusyModel(model.id)
|
|
136
|
+
try {
|
|
137
|
+
setState(await api.reorder(model.id, order))
|
|
138
|
+
setError(null)
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
141
|
+
setState(prev => prev === null ? prev : {
|
|
142
|
+
...prev,
|
|
143
|
+
models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
|
|
144
|
+
})
|
|
145
|
+
} finally {
|
|
146
|
+
setBusyModel(null)
|
|
147
|
+
}
|
|
148
|
+
}, [t])
|
|
149
|
+
|
|
150
|
+
const toggleQuickSwitch = useCallback(async (value: boolean) => {
|
|
151
|
+
if (state === null) return
|
|
152
|
+
const previous = state.showQuickSwitch
|
|
153
|
+
setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
|
|
154
|
+
setQuickSwitchBusy(true)
|
|
155
|
+
try {
|
|
156
|
+
setState(await api.setShowQuickSwitch(value))
|
|
157
|
+
setError(null)
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
|
|
160
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
161
|
+
} finally {
|
|
162
|
+
setQuickSwitchBusy(false)
|
|
163
|
+
}
|
|
164
|
+
}, [state])
|
|
165
|
+
|
|
166
|
+
const toggleIgnorePrefix = useCallback(async (value: boolean) => {
|
|
167
|
+
if (state === null) return
|
|
168
|
+
const previous = state.ignoreModelIdPrefix
|
|
169
|
+
setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
|
|
170
|
+
setIgnorePrefixBusy(true)
|
|
171
|
+
try {
|
|
172
|
+
setState(await api.setIgnoreModelIdPrefix(value))
|
|
173
|
+
setError(null)
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
|
|
176
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
177
|
+
} finally {
|
|
178
|
+
setIgnorePrefixBusy(false)
|
|
179
|
+
}
|
|
180
|
+
}, [state])
|
|
181
|
+
|
|
182
|
+
const changeModelSort = useCallback(async (mode: ModelSortMode) => {
|
|
183
|
+
if (state === null || state.modelSort === mode) return
|
|
184
|
+
const previous = state.modelSort
|
|
185
|
+
setState(prev => prev === null ? prev : { ...prev, modelSort: mode })
|
|
186
|
+
setSortBusy(true)
|
|
187
|
+
try {
|
|
188
|
+
setState(await api.setModelSort(mode))
|
|
189
|
+
setError(null)
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
setState(prev => prev === null ? prev : { ...prev, modelSort: previous })
|
|
192
|
+
setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
193
|
+
} finally {
|
|
194
|
+
setSortBusy(false)
|
|
195
|
+
}
|
|
196
|
+
}, [state, t])
|
|
197
|
+
|
|
198
|
+
const moveModel = useCallback(async (from: number, to: number) => {
|
|
199
|
+
if (state === null || from === to) return
|
|
200
|
+
// The drag list shows the full display order (modelOrder + catalog
|
|
201
|
+
// tail); persist the whole reordered id list as the new custom order.
|
|
202
|
+
const ids = state.models.map(model => model.id)
|
|
203
|
+
const order = arrayMove(ids, from, to)
|
|
204
|
+
setState(prev => prev === null ? prev : { ...prev, modelOrder: order })
|
|
205
|
+
setSortBusy(true)
|
|
206
|
+
try {
|
|
207
|
+
setState(await api.setModelOrder(order))
|
|
208
|
+
setError(null)
|
|
209
|
+
} catch (cause) {
|
|
210
|
+
setState(prev => prev === null ? prev : { ...prev, modelOrder: state.modelOrder })
|
|
211
|
+
setError(t('sortFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
212
|
+
} finally {
|
|
213
|
+
setSortBusy(false)
|
|
214
|
+
}
|
|
215
|
+
}, [state, t])
|
|
216
|
+
|
|
217
|
+
// The namespace is served by the host once the scope is ready. While it is
|
|
218
|
+
// merely loading, keep the card mounted (the tab already dispatched it);
|
|
219
|
+
// if it is unavailable (deployment without the host half), render nothing.
|
|
220
|
+
if (snapshot.status === 'unavailable') return null
|
|
221
|
+
const writable = snapshot.writable
|
|
222
|
+
|
|
223
|
+
const models = useMemo(() => state?.models ?? [], [state])
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<li className="mr-settings-card" data-open={open ? '' : undefined}>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="mr-settings-head"
|
|
230
|
+
aria-expanded={open}
|
|
231
|
+
aria-label={(open ? t('collapse') : t('expand')) + ':' + t('settingsTitle')}
|
|
232
|
+
onClick={() => setOpen(!open)}
|
|
233
|
+
>
|
|
234
|
+
<span className="mr-settings-head-text">
|
|
235
|
+
<span className="mr-settings-title">{t('settingsTitle')}</span>
|
|
236
|
+
<span className="mr-settings-sub">{t('settingsCardDescription')}</span>
|
|
237
|
+
</span>
|
|
238
|
+
<span className="mr-settings-chevron" data-open={open ? '' : undefined} aria-hidden="true">
|
|
239
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
240
|
+
<path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
|
|
241
|
+
strokeLinecap="round" strokeLinejoin="round" />
|
|
242
|
+
</svg>
|
|
243
|
+
</span>
|
|
244
|
+
</button>
|
|
245
|
+
{open && (
|
|
246
|
+
<div className="mr-settings-body">
|
|
247
|
+
{!writable && (
|
|
248
|
+
<p className="mr-settings-readonly" role="status">{t('readOnly')}</p>
|
|
249
|
+
)}
|
|
250
|
+
<div className="mr-root">
|
|
251
|
+
<label className="mr-quick-switch">
|
|
252
|
+
<input
|
|
253
|
+
type="checkbox"
|
|
254
|
+
checked={state?.showQuickSwitch ?? true}
|
|
255
|
+
disabled={loading || quickSwitchBusy || !writable}
|
|
256
|
+
onChange={(event) => void toggleQuickSwitch(event.target.checked)}
|
|
257
|
+
/>
|
|
258
|
+
<span className="mr-quick-switch-copy">
|
|
259
|
+
<span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
|
|
260
|
+
<span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
|
|
261
|
+
</span>
|
|
262
|
+
</label>
|
|
263
|
+
<label className="mr-quick-switch">
|
|
264
|
+
<input
|
|
265
|
+
type="checkbox"
|
|
266
|
+
checked={state?.ignoreModelIdPrefix ?? true}
|
|
267
|
+
disabled={loading || ignorePrefixBusy || !writable}
|
|
268
|
+
onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
|
|
269
|
+
/>
|
|
270
|
+
<span className="mr-quick-switch-copy">
|
|
271
|
+
<span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
|
|
272
|
+
<span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
|
|
273
|
+
</span>
|
|
274
|
+
</label>
|
|
275
|
+
<div className="mr-sort">
|
|
276
|
+
<div className="mr-sort-head">
|
|
277
|
+
<span className="mr-sort-title">{t('sortTitle')}</span>
|
|
278
|
+
<span className="mr-sort-desc">{t('sortDescription')}</span>
|
|
279
|
+
</div>
|
|
280
|
+
<div className="mr-sort-modes" role="radiogroup" aria-label={t('sortTitle')}>
|
|
281
|
+
{(['custom', 'name', 'recent'] as const).map(mode => (
|
|
282
|
+
<label key={mode} className="mr-sort-mode">
|
|
283
|
+
<input
|
|
284
|
+
type="radio"
|
|
285
|
+
name="mr-model-sort"
|
|
286
|
+
checked={state?.modelSort === mode}
|
|
287
|
+
disabled={loading || sortBusy || !writable}
|
|
288
|
+
onChange={() => void changeModelSort(mode)}
|
|
289
|
+
/>
|
|
290
|
+
<span className="mr-sort-mode-copy">
|
|
291
|
+
<span className="mr-sort-mode-label">{t(`sortMode${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
292
|
+
<span className="mr-sort-mode-desc">{t(`sortModeDesc${mode[0].toUpperCase()}${mode.slice(1)}`)}</span>
|
|
293
|
+
</span>
|
|
294
|
+
</label>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
{state?.modelSort === 'custom' && (
|
|
298
|
+
<div className="mr-sort-custom">
|
|
299
|
+
<p className="mr-sort-hint">{t('sortCustomHint')}</p>
|
|
300
|
+
<ol className="mr-sort-list">
|
|
301
|
+
{models.map((model, index) => {
|
|
302
|
+
const dropTarget = modelDrag !== null && modelDrag.index !== index
|
|
303
|
+
return (
|
|
304
|
+
<li
|
|
305
|
+
key={model.id}
|
|
306
|
+
className={[
|
|
307
|
+
'mr-sort-item',
|
|
308
|
+
dropTarget ? 'mr-sort-item-drop' : '',
|
|
309
|
+
sortBusy ? 'mr-sort-item-busy' : '',
|
|
310
|
+
].filter(Boolean).join(' ')}
|
|
311
|
+
draggable={!sortBusy && writable}
|
|
312
|
+
onDragStart={(event) => {
|
|
313
|
+
setModelDrag({ index })
|
|
314
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
315
|
+
try {
|
|
316
|
+
event.dataTransfer.setData('text/plain', model.id)
|
|
317
|
+
} catch {
|
|
318
|
+
// drag data is cosmetic for our own handler
|
|
319
|
+
}
|
|
320
|
+
}}
|
|
321
|
+
onDragOver={(event) => {
|
|
322
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
323
|
+
event.preventDefault()
|
|
324
|
+
event.dataTransfer.dropEffect = 'move'
|
|
325
|
+
}
|
|
326
|
+
}}
|
|
327
|
+
onDrop={(event) => {
|
|
328
|
+
if (modelDrag !== null && modelDrag.index !== index) {
|
|
329
|
+
event.preventDefault()
|
|
330
|
+
moveModel(modelDrag.index, index)
|
|
331
|
+
}
|
|
332
|
+
setModelDrag(null)
|
|
333
|
+
}}
|
|
334
|
+
onDragEnd={() => setModelDrag(null)}
|
|
335
|
+
>
|
|
336
|
+
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
337
|
+
<span className="mr-sort-model-name">{model.name}</span>
|
|
338
|
+
<span className="mr-sort-model-id">{model.id}</span>
|
|
339
|
+
</li>
|
|
340
|
+
)
|
|
341
|
+
})}
|
|
342
|
+
</ol>
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
<div className="mr-toolbar">
|
|
347
|
+
<span className="mr-count">{t('modelsCount', { count: models.length })}</span>
|
|
348
|
+
<button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
|
|
349
|
+
{t('refresh')}
|
|
350
|
+
</button>
|
|
351
|
+
</div>
|
|
352
|
+
{error !== null && (
|
|
353
|
+
<div className="mr-error" role="alert">{error}</div>
|
|
354
|
+
)}
|
|
355
|
+
{loading && models.length === 0 && (
|
|
356
|
+
<div className="mr-empty">{t('loading')}</div>
|
|
357
|
+
)}
|
|
358
|
+
{!loading && models.length === 0 && (
|
|
359
|
+
<div className="mr-empty">{t('empty')}</div>
|
|
360
|
+
)}
|
|
361
|
+
<div className="mr-models">
|
|
362
|
+
{models.map(model => (
|
|
363
|
+
<ModelCard
|
|
364
|
+
key={model.id}
|
|
365
|
+
model={model}
|
|
366
|
+
t={t}
|
|
367
|
+
readOnly={!writable}
|
|
368
|
+
busy={busyModel === model.id}
|
|
369
|
+
dragging={drag}
|
|
370
|
+
onDragStart={index => setDrag({ modelId: model.id, index })}
|
|
371
|
+
onDragEnd={() => setDrag(null)}
|
|
372
|
+
onDrop={index => {
|
|
373
|
+
if (drag !== null && drag.modelId === model.id) {
|
|
374
|
+
void reorder(model, drag.index, index)
|
|
375
|
+
}
|
|
376
|
+
setDrag(null)
|
|
377
|
+
}}
|
|
378
|
+
onSwitch={providerId => void switchActive(model, providerId)}
|
|
379
|
+
/>
|
|
380
|
+
))}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</li>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface ModelCardProps {
|
|
391
|
+
model: RouterModelView
|
|
392
|
+
t: Translate
|
|
393
|
+
readOnly: boolean
|
|
394
|
+
busy: boolean
|
|
395
|
+
dragging: { modelId: string; index: number } | null
|
|
396
|
+
onDragStart: (index: number) => void
|
|
397
|
+
onDragEnd: () => void
|
|
398
|
+
onDrop: (index: number) => void
|
|
399
|
+
onSwitch: (providerId: string) => void
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function ModelCard(props: ModelCardProps): JSX.Element {
|
|
403
|
+
const { model, t, readOnly, busy, dragging } = props
|
|
404
|
+
return (
|
|
405
|
+
<section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
|
|
406
|
+
<header className="mr-card-header">
|
|
407
|
+
<span className="mr-model-name">{model.name}</span>
|
|
408
|
+
<span className="mr-model-id">{model.id}</span>
|
|
409
|
+
<span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
|
|
410
|
+
</header>
|
|
411
|
+
<p className="mr-drag-hint">{t('dragHint')}</p>
|
|
412
|
+
<ul className="mr-providers">
|
|
413
|
+
{model.providers.map((provider, index) => {
|
|
414
|
+
const active = provider.id === model.active
|
|
415
|
+
const selectable = provider.credentialConfigured
|
|
416
|
+
const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
|
|
417
|
+
return (
|
|
418
|
+
<li
|
|
419
|
+
key={provider.id}
|
|
420
|
+
className={[
|
|
421
|
+
'mr-provider',
|
|
422
|
+
active ? 'mr-provider-active' : '',
|
|
423
|
+
!selectable ? 'mr-provider-nocred' : '',
|
|
424
|
+
dropTarget ? 'mr-provider-drop' : '',
|
|
425
|
+
].filter(Boolean).join(' ')}
|
|
426
|
+
draggable={!busy && !readOnly && model.providers.length > 1}
|
|
427
|
+
title={selectable ? undefined : t('noCredential')}
|
|
428
|
+
onDragStart={(event) => {
|
|
429
|
+
props.onDragStart(index)
|
|
430
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
431
|
+
try {
|
|
432
|
+
event.dataTransfer.setData('text/plain', model.id)
|
|
433
|
+
} catch {
|
|
434
|
+
// drag data is cosmetic for our own handler
|
|
435
|
+
}
|
|
436
|
+
}}
|
|
437
|
+
onDragOver={(event) => {
|
|
438
|
+
if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
|
|
439
|
+
event.preventDefault()
|
|
440
|
+
event.dataTransfer.dropEffect = 'move'
|
|
441
|
+
}
|
|
442
|
+
}}
|
|
443
|
+
onDrop={(event) => {
|
|
444
|
+
if (dragging !== null && dragging.modelId === model.id) {
|
|
445
|
+
event.preventDefault()
|
|
446
|
+
props.onDrop(index)
|
|
447
|
+
}
|
|
448
|
+
}}
|
|
449
|
+
onDragEnd={() => props.onDragEnd()}
|
|
450
|
+
>
|
|
451
|
+
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
className="mr-provider-button"
|
|
455
|
+
disabled={!selectable || busy || active || readOnly}
|
|
456
|
+
onClick={() => props.onSwitch(provider.id)}
|
|
457
|
+
>
|
|
458
|
+
<span className="mr-provider-name">{provider.name}</span>
|
|
459
|
+
{!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
|
|
460
|
+
</button>
|
|
461
|
+
{active && <span className="mr-active-badge">{t('activeBadge')}</span>}
|
|
462
|
+
</li>
|
|
463
|
+
)
|
|
464
|
+
})}
|
|
465
|
+
</ul>
|
|
466
|
+
</section>
|
|
467
|
+
)
|
|
468
|
+
}
|
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
|
}
|