@fonlan/dsh-model-router 0.1.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +368 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +626 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/server/adapter.d.ts +42 -0
- package/lib/types/server/catalog.d.ts +24 -0
- package/lib/types/server/rpc.d.ts +3 -0
- package/lib/types/server/service.d.ts +66 -0
- package/lib/types/shared/config.d.ts +92 -0
- package/package.json +99 -0
- package/src/client/api.ts +56 -0
- package/src/client/index.tsx +35 -0
- package/src/client/locales.ts +39 -0
- package/src/client/settings-section.css +210 -0
- package/src/client/settings-section.tsx +235 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +37 -0
- package/src/server/adapter.ts +99 -0
- package/src/server/catalog.ts +128 -0
- package/src/server/rpc.ts +147 -0
- package/src/server/service.ts +239 -0
- package/src/shared/config.ts +231 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Model Router settings page: one card per merged model, provider chips
|
|
3
|
+
* that switch the active route on click and reorder by native HTML5 drag &
|
|
4
|
+
* drop (preference order for future automatic failover). All reads/mutations
|
|
5
|
+
* go through the plugin's fenced API; the server persists through the
|
|
6
|
+
* settings service, so a switch here is live for the next request globally.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
9
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
|
+
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|
11
|
+
import { LOCALE_NS } from './locales'
|
|
12
|
+
import { api, type ModelRouterState, type RouterModelView } from './api'
|
|
13
|
+
import './settings-section.css'
|
|
14
|
+
|
|
15
|
+
function arrayMove<T>(list: readonly T[], from: number, to: number): T[] {
|
|
16
|
+
const next = [...list]
|
|
17
|
+
const [item] = next.splice(from, 1)
|
|
18
|
+
next.splice(to, 0, item)
|
|
19
|
+
return next
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function useLocaleRevision(ctx: ClientContext): number {
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(onChange: () => void) => {
|
|
25
|
+
try {
|
|
26
|
+
return ctx.locale.subscribe(onChange)
|
|
27
|
+
} catch {
|
|
28
|
+
return () => {}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
[ctx],
|
|
32
|
+
)
|
|
33
|
+
const getSnapshot = useCallback(() => {
|
|
34
|
+
try {
|
|
35
|
+
return ctx.locale.getLocale().revision
|
|
36
|
+
} catch {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
}, [ctx])
|
|
40
|
+
return useSyncExternalStore(subscribe, getSnapshot)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
|
|
44
|
+
// Bound translation is namespace-typed; the section's props use the plain
|
|
45
|
+
// Translate face (string keys), which the dict satisfies structurally.
|
|
46
|
+
const t: Translate = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return ctx.locale.bind(LOCALE_NS) as unknown as Translate
|
|
49
|
+
} catch {
|
|
50
|
+
return (key: string) => key
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
|
|
54
|
+
return function ModelRouterSettingsSection(): JSX.Element {
|
|
55
|
+
useLocaleRevision(ctx)
|
|
56
|
+
const [state, setState] = useState<ModelRouterState | null>(null)
|
|
57
|
+
const [error, setError] = useState<string | null>(null)
|
|
58
|
+
const [loading, setLoading] = useState(true)
|
|
59
|
+
const [busyModel, setBusyModel] = useState<string | null>(null)
|
|
60
|
+
const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
|
|
61
|
+
|
|
62
|
+
const load = useCallback(async () => {
|
|
63
|
+
setLoading(true)
|
|
64
|
+
try {
|
|
65
|
+
setState(await api.state())
|
|
66
|
+
setError(null)
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
}
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
void load()
|
|
76
|
+
}, [load])
|
|
77
|
+
|
|
78
|
+
const switchActive = useCallback(async (model: RouterModelView, providerId: string) => {
|
|
79
|
+
if (model.active === providerId) return
|
|
80
|
+
setBusyModel(model.id)
|
|
81
|
+
try {
|
|
82
|
+
setState(await api.switchActive(model.id, providerId))
|
|
83
|
+
setError(null)
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
setError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
86
|
+
} finally {
|
|
87
|
+
setBusyModel(null)
|
|
88
|
+
}
|
|
89
|
+
}, [t])
|
|
90
|
+
|
|
91
|
+
const reorder = useCallback(async (model: RouterModelView, from: number, to: number) => {
|
|
92
|
+
if (from === to) return
|
|
93
|
+
const order = arrayMove(model.order, from, to)
|
|
94
|
+
setState(prev => prev === null ? prev : {
|
|
95
|
+
...prev,
|
|
96
|
+
models: prev.models.map(m => (m.id === model.id ? { ...m, order } : m)),
|
|
97
|
+
})
|
|
98
|
+
setBusyModel(model.id)
|
|
99
|
+
try {
|
|
100
|
+
setState(await api.reorder(model.id, order))
|
|
101
|
+
setError(null)
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
setError(t('reorderFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
|
|
104
|
+
setState(prev => prev === null ? prev : {
|
|
105
|
+
...prev,
|
|
106
|
+
models: prev.models.map(m => (m.id === model.id ? { ...m, order: model.order } : m)),
|
|
107
|
+
})
|
|
108
|
+
} finally {
|
|
109
|
+
setBusyModel(null)
|
|
110
|
+
}
|
|
111
|
+
}, [t])
|
|
112
|
+
|
|
113
|
+
const models = useMemo(() => state?.models ?? [], [state])
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="mr-root">
|
|
117
|
+
<p className="mr-description">{t('settingsDescription')}</p>
|
|
118
|
+
<div className="mr-toolbar">
|
|
119
|
+
<span className="mr-count">{t('modelsCount', { count: models.length })}</span>
|
|
120
|
+
<button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>
|
|
121
|
+
{t('refresh')}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
{error !== null && (
|
|
125
|
+
<div className="mr-error" role="alert">{error}</div>
|
|
126
|
+
)}
|
|
127
|
+
{loading && models.length === 0 && (
|
|
128
|
+
<div className="mr-empty">{t('loading')}</div>
|
|
129
|
+
)}
|
|
130
|
+
{!loading && models.length === 0 && (
|
|
131
|
+
<div className="mr-empty">{t('empty')}</div>
|
|
132
|
+
)}
|
|
133
|
+
<div className="mr-models">
|
|
134
|
+
{models.map(model => (
|
|
135
|
+
<ModelCard
|
|
136
|
+
key={model.id}
|
|
137
|
+
model={model}
|
|
138
|
+
t={t}
|
|
139
|
+
busy={busyModel === model.id}
|
|
140
|
+
dragging={drag}
|
|
141
|
+
onDragStart={index => setDrag({ modelId: model.id, index })}
|
|
142
|
+
onDragEnd={() => setDrag(null)}
|
|
143
|
+
onDrop={index => {
|
|
144
|
+
if (drag !== null && drag.modelId === model.id) {
|
|
145
|
+
void reorder(model, drag.index, index)
|
|
146
|
+
}
|
|
147
|
+
setDrag(null)
|
|
148
|
+
}}
|
|
149
|
+
onSwitch={providerId => void switchActive(model, providerId)}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ModelCardProps {
|
|
159
|
+
model: RouterModelView
|
|
160
|
+
t: Translate
|
|
161
|
+
busy: boolean
|
|
162
|
+
dragging: { modelId: string; index: number } | null
|
|
163
|
+
onDragStart: (index: number) => void
|
|
164
|
+
onDragEnd: () => void
|
|
165
|
+
onDrop: (index: number) => void
|
|
166
|
+
onSwitch: (providerId: string) => void
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ModelCard(props: ModelCardProps): JSX.Element {
|
|
170
|
+
const { model, t, busy, dragging } = props
|
|
171
|
+
return (
|
|
172
|
+
<section className={`mr-card${busy ? ' mr-card-busy' : ''}`}>
|
|
173
|
+
<header className="mr-card-header">
|
|
174
|
+
<span className="mr-model-name">{model.name}</span>
|
|
175
|
+
<span className="mr-model-id">{model.id}</span>
|
|
176
|
+
<span className="mr-provider-count">{t('providerCount', { count: model.providers.length })}</span>
|
|
177
|
+
</header>
|
|
178
|
+
<p className="mr-drag-hint">{t('dragHint')}</p>
|
|
179
|
+
<ul className="mr-providers">
|
|
180
|
+
{model.providers.map((provider, index) => {
|
|
181
|
+
const active = provider.id === model.active
|
|
182
|
+
const selectable = provider.credentialConfigured
|
|
183
|
+
const dropTarget = dragging !== null && dragging.modelId === model.id && dragging.index !== index
|
|
184
|
+
return (
|
|
185
|
+
<li
|
|
186
|
+
key={provider.id}
|
|
187
|
+
className={[
|
|
188
|
+
'mr-provider',
|
|
189
|
+
active ? 'mr-provider-active' : '',
|
|
190
|
+
!selectable ? 'mr-provider-nocred' : '',
|
|
191
|
+
dropTarget ? 'mr-provider-drop' : '',
|
|
192
|
+
].filter(Boolean).join(' ')}
|
|
193
|
+
draggable={!busy && model.providers.length > 1}
|
|
194
|
+
title={selectable ? undefined : t('noCredential')}
|
|
195
|
+
onDragStart={(event) => {
|
|
196
|
+
props.onDragStart(index)
|
|
197
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
198
|
+
try {
|
|
199
|
+
event.dataTransfer.setData('text/plain', model.id)
|
|
200
|
+
} catch {
|
|
201
|
+
// drag data is cosmetic for our own handler
|
|
202
|
+
}
|
|
203
|
+
}}
|
|
204
|
+
onDragOver={(event) => {
|
|
205
|
+
if (dragging !== null && dragging.modelId === model.id && dragging.index !== index) {
|
|
206
|
+
event.preventDefault()
|
|
207
|
+
event.dataTransfer.dropEffect = 'move'
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
210
|
+
onDrop={(event) => {
|
|
211
|
+
if (dragging !== null && dragging.modelId === model.id) {
|
|
212
|
+
event.preventDefault()
|
|
213
|
+
props.onDrop(index)
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
onDragEnd={() => props.onDragEnd()}
|
|
217
|
+
>
|
|
218
|
+
<span className="mr-grip" aria-hidden="true">⠿</span>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
className="mr-provider-button"
|
|
222
|
+
disabled={!selectable || busy || active}
|
|
223
|
+
onClick={() => props.onSwitch(provider.id)}
|
|
224
|
+
>
|
|
225
|
+
<span className="mr-provider-name">{provider.name}</span>
|
|
226
|
+
{!selectable && <span className="mr-provider-nocred-label">· {t('noCredential')}</span>}
|
|
227
|
+
</button>
|
|
228
|
+
{active && <span className="mr-active-badge">{t('activeBadge')}</span>}
|
|
229
|
+
</li>
|
|
230
|
+
)
|
|
231
|
+
})}
|
|
232
|
+
</ul>
|
|
233
|
+
</section>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare this plugin's locale namespace so slot registrations can type-check
|
|
3
|
+
* the `locale` seat. zh is the key source of truth; en must carry the exact
|
|
4
|
+
* same key set (checked at runtime by the locale registry).
|
|
5
|
+
*/
|
|
6
|
+
import type { zh } from './locales'
|
|
7
|
+
|
|
8
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
9
|
+
interface LocaleNamespaceMap {
|
|
10
|
+
'model-router': keyof typeof zh
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-model-router host half: the virtual model-router provider.
|
|
3
|
+
*
|
|
4
|
+
* Registers one `model-router` LLM adapter that aggregates every configured
|
|
5
|
+
* provider and model (strict model-id merge), routes each request to the
|
|
6
|
+
* model's currently active provider by in-process delegation, persists the
|
|
7
|
+
* per-model provider order/active config in the `model-router` settings
|
|
8
|
+
* namespace, and serves the fenced JSON API the web settings page calls.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
|
+
import { ModelRouterService } from './server/service.js'
|
|
13
|
+
import { registerApiRoutes } from './server/rpc.js'
|
|
14
|
+
|
|
15
|
+
export const name = '@fonlan/dsh-model-router'
|
|
16
|
+
|
|
17
|
+
// The llm service is part of dsh-base (required); the settings service and
|
|
18
|
+
// web server are attached opportunistically so the router works in web, CLI,
|
|
19
|
+
// and headless profiles alike (headless profiles route by config but expose
|
|
20
|
+
// no settings page).
|
|
21
|
+
export const inject = ['llm']
|
|
22
|
+
|
|
23
|
+
export const Config = z.object({})
|
|
24
|
+
|
|
25
|
+
export function apply(ctx: Context): void {
|
|
26
|
+
const service = new ModelRouterService(ctx)
|
|
27
|
+
|
|
28
|
+
ctx.effect(() => {
|
|
29
|
+
service.start()
|
|
30
|
+
return () => service.stop()
|
|
31
|
+
}, 'model-router: service')
|
|
32
|
+
|
|
33
|
+
// The settings-page API only exists where a web server does.
|
|
34
|
+
ctx.inject(['webServer'], (sctx) => {
|
|
35
|
+
sctx.effect(() => registerApiRoutes(sctx, service), 'model-router: api routes')
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The virtual `model-router` LLM adapter. Registered for the single provider
|
|
3
|
+
* route `model-router`, it advertises the merged catalog and, at request time,
|
|
4
|
+
* rewrites the provider field to the model's currently active provider and
|
|
5
|
+
* delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
|
|
6
|
+
* and metering all stay inside the real adapter's pipeline.
|
|
7
|
+
*/
|
|
8
|
+
import { LlmAdapter, LlmError, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
9
|
+
import type {
|
|
10
|
+
GenerateOptions,
|
|
11
|
+
LlmModelInfo,
|
|
12
|
+
LlmProviderInfo,
|
|
13
|
+
LlmResolvedModelInfo,
|
|
14
|
+
StreamChunk,
|
|
15
|
+
} from '@deepseek-ai/dsh-llm'
|
|
16
|
+
import {
|
|
17
|
+
displayProvider,
|
|
18
|
+
resolveActive,
|
|
19
|
+
ROUTER_PROVIDER_ID,
|
|
20
|
+
type MergedModel,
|
|
21
|
+
type RouterConfigShape,
|
|
22
|
+
} from '../shared/config.js'
|
|
23
|
+
|
|
24
|
+
/** Facts the adapter reads from the owning service (live, per request). */
|
|
25
|
+
export interface RouterFacts {
|
|
26
|
+
/** Current merged catalog (refreshed on topology changes). */
|
|
27
|
+
catalog(): { models: readonly MergedModel[]; byId: ReadonlyMap<string, MergedModel> }
|
|
28
|
+
/** Current stored router config. */
|
|
29
|
+
config(): RouterConfigShape
|
|
30
|
+
/** Provider display names by id. */
|
|
31
|
+
providerNames(): ReadonlyMap<string, string>
|
|
32
|
+
/** The LLM runtime to delegate to. */
|
|
33
|
+
llm(): LlmRuntime
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One resolved route for a model id. */
|
|
37
|
+
export interface ResolvedRoute {
|
|
38
|
+
provider: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the effective route for one model id: the stored active provider
|
|
43
|
+
* when still present, otherwise the first remaining order entry, otherwise
|
|
44
|
+
* the catalog head. Unadvertised model ids do not route.
|
|
45
|
+
*/
|
|
46
|
+
export function routeFor(facts: RouterFacts, modelId: string): ResolvedRoute | null {
|
|
47
|
+
const merged = facts.catalog().byId.get(modelId)
|
|
48
|
+
if (merged === undefined) return null
|
|
49
|
+
const ids = merged.providers.map(provider => provider.provider)
|
|
50
|
+
const active = resolveActive(facts.config(), modelId, ids)
|
|
51
|
+
return active === null ? null : { provider: active }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ModelRouterAdapter extends LlmAdapter {
|
|
55
|
+
constructor(private readonly facts: RouterFacts) {
|
|
56
|
+
super()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
providerInfo(provider: string): LlmProviderInfo {
|
|
60
|
+
return { id: provider, name: 'Model Router' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
|
64
|
+
const { models } = this.facts.catalog()
|
|
65
|
+
const names = this.facts.providerNames()
|
|
66
|
+
const out: LlmModelInfo[] = []
|
|
67
|
+
for (const merged of models) {
|
|
68
|
+
const route = routeFor(this.facts, merged.id)
|
|
69
|
+
const display = displayProvider(merged, route?.provider)
|
|
70
|
+
if (display === null) continue
|
|
71
|
+
out.push({
|
|
72
|
+
provider: ROUTER_PROVIDER_ID,
|
|
73
|
+
id: merged.id,
|
|
74
|
+
name: display.name,
|
|
75
|
+
description: `via ${names.get(display.provider) ?? display.provider}`,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
|
|
82
|
+
const route = routeFor(this.facts, model)
|
|
83
|
+
if (route === null) {
|
|
84
|
+
throw new LlmError(`model-router: no provider serves model "${model}"`, 'NO_ADAPTER')
|
|
85
|
+
}
|
|
86
|
+
const resolved = await this.facts.llm().resolveModelInfo(route.provider, model, signal)
|
|
87
|
+
return { ...resolved, provider: ROUTER_PROVIDER_ID }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
91
|
+
const route = routeFor(this.facts, options.model)
|
|
92
|
+
if (route === null) {
|
|
93
|
+
throw new LlmError(`model-router: no provider serves model "${options.model}"`, 'NO_ADAPTER')
|
|
94
|
+
}
|
|
95
|
+
// In-process delegation: the inner call resolves the real adapter and
|
|
96
|
+
// runs its full pipeline (retry, waterfall, replay-state stripping).
|
|
97
|
+
yield* this.facts.llm().stream({ ...options, provider: route.provider })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider discovery for the router: every registered LLM provider route
|
|
3
|
+
* (minus the router itself) becomes a ProviderInfo, models merge by exact id
|
|
4
|
+
* through the shared core, and per-provider credential status is detected
|
|
5
|
+
* generically through the configurable-provider directory + the credentials
|
|
6
|
+
* seam.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { credentialRef, type CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
|
10
|
+
import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
|
|
11
|
+
import { mergeModels, ROUTER_PROVIDER_ID, type MergedModel, type ProviderInfo } from '../shared/config.js'
|
|
12
|
+
|
|
13
|
+
/** Live router catalog: everything the router can advertise and route to. */
|
|
14
|
+
export interface RouterCatalog {
|
|
15
|
+
/** All providers (minus the router) in registration order. */
|
|
16
|
+
providers: ProviderInfo[]
|
|
17
|
+
/** Provider id → human display name (configurable-directory name when known). */
|
|
18
|
+
providerNames: Map<string, string>
|
|
19
|
+
/** Models merged by exact id across providers. */
|
|
20
|
+
models: MergedModel[]
|
|
21
|
+
/** Model id → merged model. */
|
|
22
|
+
byId: Map<string, MergedModel>
|
|
23
|
+
/** Provider id → whether the provider currently has usable credentials. */
|
|
24
|
+
credentialConfigured: Map<string, boolean>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EMPTY_CATALOG: RouterCatalog = {
|
|
28
|
+
providers: [],
|
|
29
|
+
providerNames: new Map(),
|
|
30
|
+
models: [],
|
|
31
|
+
byId: new Map(),
|
|
32
|
+
credentialConfigured: new Map(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ConfigurableEntry {
|
|
36
|
+
provider: string
|
|
37
|
+
displayName: string
|
|
38
|
+
settingsNs: string
|
|
39
|
+
settingsPath: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read the resolved value of another plugin's settings section, if registered. */
|
|
43
|
+
function settingsSection(ctx: Context, ns: string): unknown {
|
|
44
|
+
try {
|
|
45
|
+
return (ctx.settings as SettingsProvider).get(ns as never)
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Determine whether one provider has usable credentials. Generic: the
|
|
53
|
+
* configurable-provider directory names the settings section + path where the
|
|
54
|
+
* provider's profile (and its optional apiKeyEnv) lives; the credentials seam
|
|
55
|
+
* answers whether that reference currently resolves. Providers without a
|
|
56
|
+
* directory entry or without an apiKeyEnv answer "configured" (unknown —
|
|
57
|
+
* never gray out what we cannot verify).
|
|
58
|
+
*/
|
|
59
|
+
async function detectCredentialConfigured(
|
|
60
|
+
ctx: Context,
|
|
61
|
+
entry: ConfigurableEntry | undefined,
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
if (entry === undefined) return true
|
|
64
|
+
const section = settingsSection(ctx, entry.settingsNs)
|
|
65
|
+
let profile: unknown = section
|
|
66
|
+
for (const key of entry.settingsPath) {
|
|
67
|
+
if (typeof profile !== 'object' || profile === null) {
|
|
68
|
+
profile = undefined
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
profile = (profile as Record<string, unknown>)[key]
|
|
72
|
+
}
|
|
73
|
+
const apiKeyEnv = typeof profile === 'object' && profile !== null
|
|
74
|
+
? (profile as Record<string, unknown>).apiKeyEnv
|
|
75
|
+
: undefined
|
|
76
|
+
if (typeof apiKeyEnv !== 'string' || apiKeyEnv.trim() === '') return true
|
|
77
|
+
let credentials: CredentialProvider | undefined
|
|
78
|
+
try {
|
|
79
|
+
credentials = ctx.get('credentials') as CredentialProvider | undefined
|
|
80
|
+
} catch {
|
|
81
|
+
credentials = undefined
|
|
82
|
+
}
|
|
83
|
+
if (credentials === undefined) return true
|
|
84
|
+
try {
|
|
85
|
+
return (await credentials.describe(credentialRef(apiKeyEnv.trim()))).configured
|
|
86
|
+
} catch {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rebuild the live router catalog from the LLM registry. */
|
|
92
|
+
export async function buildCatalog(ctx: Context): Promise<RouterCatalog> {
|
|
93
|
+
const routes = ctx.llm.listProviders().filter(provider => provider.id !== ROUTER_PROVIDER_ID)
|
|
94
|
+
const directory = new Map<string, ConfigurableEntry>(
|
|
95
|
+
ctx.llm.listConfigurableProviders().map(entry => [entry.provider, {
|
|
96
|
+
provider: entry.provider,
|
|
97
|
+
displayName: entry.displayName,
|
|
98
|
+
settingsNs: entry.settingsNs,
|
|
99
|
+
settingsPath: entry.settingsPath,
|
|
100
|
+
}]),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const providers: ProviderInfo[] = []
|
|
104
|
+
const providerNames = new Map<string, string>()
|
|
105
|
+
const credentialConfigured = new Map<string, boolean>()
|
|
106
|
+
|
|
107
|
+
for (const route of routes) {
|
|
108
|
+
const entry = directory.get(route.id)
|
|
109
|
+
providerNames.set(route.id, entry?.displayName || route.name || route.id)
|
|
110
|
+
const seen = new Set<string>()
|
|
111
|
+
const models: Array<{ id: string; name: string; contextWindow?: number; maxTokens?: number }> = []
|
|
112
|
+
try {
|
|
113
|
+
for (const model of await ctx.llm.listModels(route.id)) {
|
|
114
|
+
if (seen.has(model.id)) continue
|
|
115
|
+
seen.add(model.id)
|
|
116
|
+
models.push({ id: model.id, name: model.name })
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// provider whose catalog is unavailable contributes no models
|
|
120
|
+
}
|
|
121
|
+
providers.push({ id: route.id, name: route.name, models })
|
|
122
|
+
credentialConfigured.set(route.id, await detectCredentialConfigured(ctx, entry))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const models = mergeModels(providers)
|
|
126
|
+
const byId = new Map(models.map(model => [model.id, model]))
|
|
127
|
+
return { providers, providerNames, models, byId, credentialConfigured }
|
|
128
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced JSON API under /plugins/@fonlan/dsh-model-router/api/<method> for the
|
|
3
|
+
* web settings page. Same browser-trust fence as the /api gateway and the
|
|
4
|
+
* task-kanban plugin: loopback Host-header or configured trustedHosts,
|
|
5
|
+
* same-origin browser markers.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
import type { ModelRouterService } from './service.js'
|
|
11
|
+
|
|
12
|
+
const API_PREFIX = '/plugins/@fonlan/dsh-model-router/api'
|
|
13
|
+
|
|
14
|
+
// ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
|
|
15
|
+
|
|
16
|
+
function header(headers: IncomingMessage['headers'], name: string): string | undefined {
|
|
17
|
+
const value = headers[name]
|
|
18
|
+
return typeof value === 'string' ? value : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
22
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
23
|
+
const parts = hostname.split('.')
|
|
24
|
+
return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
28
|
+
const host = header(req.headers, 'host')
|
|
29
|
+
if (host === undefined) return false
|
|
30
|
+
let hostUrl: URL
|
|
31
|
+
try {
|
|
32
|
+
hostUrl = new URL(`http://${host}`)
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
40
|
+
const origin = header(req.headers, 'origin')
|
|
41
|
+
if (origin === undefined) return true
|
|
42
|
+
try {
|
|
43
|
+
return new URL(origin).host === hostUrl.host
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trustedHostsOf(ctx: Context): string[] {
|
|
50
|
+
for (const entry of ctx.get('loader')?.entries?.() ?? []) {
|
|
51
|
+
if (entry.options?.name === 'connection') {
|
|
52
|
+
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
|
53
|
+
return config?.trustedHosts ?? []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── wire helpers ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function writeJson(res: ServerResponse, body: unknown): void {
|
|
62
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
63
|
+
res.end(JSON.stringify(body))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
|
|
67
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
68
|
+
res.end(JSON.stringify({ ok: false, error: { code, message } }))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
72
|
+
const chunks: Buffer[] = []
|
|
73
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
74
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
75
|
+
if (raw.trim() === '') return {}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(raw) as unknown
|
|
78
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
|
79
|
+
} catch {
|
|
80
|
+
return {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── routes ──────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function registerApiRoutes(ctx: Context, service: ModelRouterService): () => void {
|
|
87
|
+
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
|
|
88
|
+
const disposers: Array<() => void> = []
|
|
89
|
+
|
|
90
|
+
const route = (name: string, handler: (payload: Record<string, unknown>) => Promise<unknown>): void => {
|
|
91
|
+
disposers.push(ctx.webServer.register({
|
|
92
|
+
kind: 'prefix',
|
|
93
|
+
path: `${API_PREFIX}/${name}`,
|
|
94
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
95
|
+
if (!fence(req)) {
|
|
96
|
+
writeError(res, 'forbidden', 'forbidden', 403)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
100
|
+
writeError(res, 'method', 'method not allowed', 405)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const payload = await readJsonBody(req)
|
|
104
|
+
try {
|
|
105
|
+
const value = await handler(payload)
|
|
106
|
+
writeJson(res, { ok: true, value })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
writeError(res, 'error', error instanceof Error ? error.message : String(error))
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
route('state', async () => service.state())
|
|
115
|
+
|
|
116
|
+
route('switch-active', async (p) => {
|
|
117
|
+
const modelId = requireString(p.modelId, 'modelId')
|
|
118
|
+
const providerId = requireString(p.providerId, 'providerId')
|
|
119
|
+
await service.switchActive(modelId, providerId)
|
|
120
|
+
return service.state()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
route('reorder', async (p) => {
|
|
124
|
+
const modelId = requireString(p.modelId, 'modelId')
|
|
125
|
+
if (!Array.isArray(p.order) || !p.order.every((id): id is string => typeof id === 'string')) {
|
|
126
|
+
throw new Error('order 必须是提供商 id 数组')
|
|
127
|
+
}
|
|
128
|
+
await service.reorder(modelId, p.order)
|
|
129
|
+
return service.state()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
route('refresh', async () => {
|
|
133
|
+
await service.refreshAndReconcile()
|
|
134
|
+
return service.state()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
for (const dispose of disposers) dispose()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requireString(value: unknown, name: string): string {
|
|
143
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
144
|
+
throw new Error(`${name} 不能为空`)
|
|
145
|
+
}
|
|
146
|
+
return value
|
|
147
|
+
}
|