@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.
@@ -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
+ }