dsh-router-core 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/README.md +151 -0
- package/cordis.patch.yml +9 -0
- package/lib/client-registry.js +3006 -0
- package/lib/client.js +3006 -0
- package/lib/index.js +1756 -0
- package/lib/types/credential-store.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/keys.d.ts +39 -0
- package/lib/types/llm/adapter.d.ts +31 -0
- package/lib/types/router/index.d.ts +73 -0
- package/lib/types/router/types.d.ts +80 -0
- package/lib/types/shared.d.ts +138 -0
- package/lib/types/supplier-config.d.ts +28 -0
- package/lib/types/suppliers/contract.d.ts +98 -0
- package/lib/types/suppliers/loader.d.ts +41 -0
- package/lib/types/suppliers/registry.d.ts +33 -0
- package/package.json +86 -0
- package/src/client/CombosTab.tsx +458 -0
- package/src/client/EndpointTab.tsx +360 -0
- package/src/client/Modal.tsx +46 -0
- package/src/client/RouterView.tsx +186 -0
- package/src/client/SupplierDetail.tsx +943 -0
- package/src/client/index.tsx +18 -0
- package/src/client/router.css +2096 -0
- package/src/client/sidebar-entry.ts +167 -0
- package/src/client/workspace-controller.ts +26 -0
- package/src/client/workspace-mount.tsx +117 -0
- package/src/credential-store.ts +64 -0
- package/src/index.ts +468 -0
- package/src/keys.ts +143 -0
- package/src/llm/adapter.ts +273 -0
- package/src/router/index.ts +273 -0
- package/src/router/types.ts +85 -0
- package/src/shared.ts +152 -0
- package/src/supplier-config.ts +144 -0
- package/src/suppliers/contract.ts +73 -0
- package/src/suppliers/loader.ts +191 -0
- package/src/suppliers/nvidia/plugin.ts +268 -0
- package/src/suppliers/opencode/plugin.ts +200 -0
- package/src/suppliers/openrouter/plugin.ts +282 -0
- package/src/suppliers/registry.ts +365 -0
- package/src/types.d.ts +45 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of dsh-router — a simplified 9router.
|
|
3
|
+
*
|
|
4
|
+
* dsh-router IS the router: it exposes an OpenAI-compatible `/v1/*` endpoint
|
|
5
|
+
* on the DSH web server (http://localhost:3080/v1), and routes requests to
|
|
6
|
+
* internal suppliers.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* POST /v1/chat/completions OpenAI-compatible chat (stream + non-stream)
|
|
10
|
+
* GET /v1/models OpenAI-compatible model list
|
|
11
|
+
* GET /router/api/health router status (panel)
|
|
12
|
+
* GET /router/api/status supplier accounts (panel)
|
|
13
|
+
* GET /router/api/models merged model list (panel)
|
|
14
|
+
* GET /router/api/combos combo fallback chains (panel)
|
|
15
|
+
* GET /router/api/keys list keys (masked)
|
|
16
|
+
* POST /router/api/keys create key {name}
|
|
17
|
+
* DELETE /router/api/keys/:id delete key
|
|
18
|
+
* PATCH /router/api/keys/:id toggle key {isActive}
|
|
19
|
+
* GET /router/api/settings { requireApiKey }
|
|
20
|
+
* PATCH /router/api/settings { requireApiKey }
|
|
21
|
+
* POST /router/api/suppliers/:id/login generate login URL
|
|
22
|
+
* POST /router/api/suppliers/:id/login/callback {callbackUrl} → add account
|
|
23
|
+
* GET /router/api/suppliers/:id/models models with enabled state
|
|
24
|
+
* PATCH /router/api/suppliers/:id/models/:mid {enabled}
|
|
25
|
+
*
|
|
26
|
+
* `/v1/*` auth: gated by KeysStore.requireApiKey — when on, Bearer must be an
|
|
27
|
+
* active library key or TW2A_API_KEY env. `/router/api/*` is same-origin, no auth.
|
|
28
|
+
*/
|
|
29
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
30
|
+
import { join, dirname } from 'node:path'
|
|
31
|
+
import { ROUTER_API_BASE } from './shared.ts'
|
|
32
|
+
import { Router } from './router/index.ts'
|
|
33
|
+
import { RouterAdapter } from './llm/adapter.ts'
|
|
34
|
+
import { KeysStore } from './keys.ts'
|
|
35
|
+
import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
36
|
+
import { supplierRoutes } from './suppliers/registry.ts'
|
|
37
|
+
import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
38
|
+
import { SupplierConfigStore } from './supplier-config.ts'
|
|
39
|
+
import { CredentialStore } from './credential-store.ts'
|
|
40
|
+
|
|
41
|
+
/** Plugin identity for cordis.yml rows. */
|
|
42
|
+
export const name = 'dsh-router'
|
|
43
|
+
|
|
44
|
+
/** Services required before mounting: the webserver (routes) + llm (设置-模型). */
|
|
45
|
+
export const inject = ['webServer', 'llm']
|
|
46
|
+
|
|
47
|
+
/** Minimal shape of the webServer service face used here. */
|
|
48
|
+
interface WebServerRoute {
|
|
49
|
+
kind: 'prefix' | 'exact'
|
|
50
|
+
path: string
|
|
51
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
52
|
+
}
|
|
53
|
+
interface WebServer {
|
|
54
|
+
register: (route: WebServerRoute) => () => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Minimal shape of the llm service faces used here (设置-模型 提供方/模型目录). */
|
|
58
|
+
interface LlmConfigurableProvider {
|
|
59
|
+
provider: string
|
|
60
|
+
displayName: string
|
|
61
|
+
settingsNs: string
|
|
62
|
+
settingsPath: readonly string[]
|
|
63
|
+
}
|
|
64
|
+
interface LlmModelDiscoveryRequest {
|
|
65
|
+
provider?: string
|
|
66
|
+
baseURL?: string
|
|
67
|
+
api?: string
|
|
68
|
+
apiKey?: string
|
|
69
|
+
signal?: AbortSignal
|
|
70
|
+
}
|
|
71
|
+
interface LlmDiscoveredModel {
|
|
72
|
+
id: string
|
|
73
|
+
name?: string
|
|
74
|
+
contextWindow?: number
|
|
75
|
+
maxTokens?: number
|
|
76
|
+
}
|
|
77
|
+
interface Llm {
|
|
78
|
+
registerConfigurableProviders: (entries: readonly LlmConfigurableProvider[]) => () => void
|
|
79
|
+
listConfigurableProviders: () => LlmConfigurableProvider[]
|
|
80
|
+
listProviders: () => Array<{ id: string; name: string }>
|
|
81
|
+
registerModelDiscovery: (
|
|
82
|
+
settingsNs: string,
|
|
83
|
+
discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
|
|
84
|
+
) => () => void
|
|
85
|
+
registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
89
|
+
const payload = JSON.stringify(body)
|
|
90
|
+
res.writeHead(status, {
|
|
91
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
92
|
+
'Cache-Control': 'no-store',
|
|
93
|
+
})
|
|
94
|
+
res.end(payload)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readBody(req: IncomingMessage, limit: number): Promise<string> {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const chunks: Buffer[] = []
|
|
100
|
+
let size = 0
|
|
101
|
+
req.on('data', (chunk: Buffer) => {
|
|
102
|
+
size += chunk.length
|
|
103
|
+
if (size > limit) {
|
|
104
|
+
reject(new Error('body_too_large'))
|
|
105
|
+
req.destroy()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
chunks.push(chunk)
|
|
109
|
+
})
|
|
110
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
111
|
+
req.on('error', reject)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Plugin body. */
|
|
116
|
+
export function apply(rawContext: unknown): void {
|
|
117
|
+
const ctx = rawContext as {
|
|
118
|
+
get: (key: string) => unknown
|
|
119
|
+
on: (name: string, listener: (...args: unknown[]) => void) => unknown
|
|
120
|
+
webServer: WebServer
|
|
121
|
+
llm?: Llm
|
|
122
|
+
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
123
|
+
effect: (fn: () => () => void, label?: string) => void
|
|
124
|
+
inject: (services: string[], callback: (sctx: unknown) => void) => void
|
|
125
|
+
}
|
|
126
|
+
const log = (msg: string): void => ctx.logger.info(`[dsh-router] ${msg}`)
|
|
127
|
+
|
|
128
|
+
const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
|
|
129
|
+
const store = new SupplierConfigStore(stateFile)
|
|
130
|
+
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
|
|
131
|
+
const router = new Router(stateFile)
|
|
132
|
+
|
|
133
|
+
const keys = new KeysStore(stateFile)
|
|
134
|
+
|
|
135
|
+
const disposers: Array<() => void> = []
|
|
136
|
+
const route = (
|
|
137
|
+
path: string,
|
|
138
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
|
139
|
+
): void => {
|
|
140
|
+
disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- 供应商注册表:内置 + 用户自定义 js + 外部插件供应商 ----
|
|
144
|
+
const loadedSuppliers: LoadedSupplier[] = []
|
|
145
|
+
/** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
|
|
146
|
+
const supplierDisposers = new Map<string, Array<() => void>>()
|
|
147
|
+
const registerSupplierRoutes = (loaded: LoadedSupplier): void => {
|
|
148
|
+
const dis: Array<() => void> = []
|
|
149
|
+
for (const r of supplierRoutes(ROUTER_API_BASE, loaded, store)) {
|
|
150
|
+
dis.push(ctx.webServer.register(r))
|
|
151
|
+
}
|
|
152
|
+
supplierDisposers.set(loaded.supplier.id, dis)
|
|
153
|
+
log(`supplier routes registered: ${loaded.supplier.id}`)
|
|
154
|
+
}
|
|
155
|
+
/** 注销一个供应商(路由 + 路由器 + 列表)。 */
|
|
156
|
+
const unregisterSupplier = (id: string): void => {
|
|
157
|
+
const i = loadedSuppliers.findIndex((l) => l.supplier.id === id)
|
|
158
|
+
if (i < 0) return
|
|
159
|
+
const [loaded] = loadedSuppliers.splice(i, 1)
|
|
160
|
+
for (const dispose of supplierDisposers.get(id) ?? []) dispose()
|
|
161
|
+
supplierDisposers.delete(id)
|
|
162
|
+
router.removeSupplier(id)
|
|
163
|
+
loaded?.supplier.dispose()
|
|
164
|
+
log(`supplier unregistered: ${id}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
168
|
+
const dataDir = dirname(stateFile)
|
|
169
|
+
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
170
|
+
loadedSuppliers.push(loaded)
|
|
171
|
+
router.add(loaded.supplier)
|
|
172
|
+
registerSupplierRoutes(loaded)
|
|
173
|
+
}
|
|
174
|
+
/** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
|
|
175
|
+
let externalSupplierIds: string[] = []
|
|
176
|
+
const loadExternal = (suppliers: Record<string, (env: SupplierEnv) => SupplierModule>): void => {
|
|
177
|
+
for (const [sid, factory] of Object.entries(suppliers)) {
|
|
178
|
+
if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
|
|
179
|
+
try {
|
|
180
|
+
const module = factory({ dataDir, log, store, credentials })
|
|
181
|
+
const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`)
|
|
182
|
+
registerLoaded(loaded)
|
|
183
|
+
externalSupplierIds.push(loaded.supplier.id)
|
|
184
|
+
log(`external supplier loaded: ${sid}`)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
ctx.logger.warn(`[dsh-router] external supplier ${sid} load failed: ${(err as Error).message}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const unloadExternal = (): void => {
|
|
191
|
+
for (const id of externalSupplierIds.splice(0)) unregisterSupplier(id)
|
|
192
|
+
}
|
|
193
|
+
// 外部插件供应商(cordis service,其它 DSH 插件提供)。
|
|
194
|
+
// 监听 internal/service:service 提供时加载,移除(插件卸载)时注销——不依赖重启。
|
|
195
|
+
ctx.on('internal/service', (name: unknown, value: unknown) => {
|
|
196
|
+
if (name !== 'router.suppliers') return
|
|
197
|
+
if (value) {
|
|
198
|
+
loadExternal(value as Record<string, (env: SupplierEnv) => SupplierModule>)
|
|
199
|
+
} else {
|
|
200
|
+
unloadExternal()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
// 兼容 cordis inject(service 已提供但 internal/service 事件可能早于本监听注册)
|
|
204
|
+
ctx.inject(['router.suppliers'], (sctx) => {
|
|
205
|
+
const c = sctx as { get?: (key: string) => unknown; router?: { suppliers?: Record<string, (env: SupplierEnv) => SupplierModule> } }
|
|
206
|
+
const service = (c.get?.('router.suppliers') ?? c.router?.suppliers) as Record<string, (env: SupplierEnv) => SupplierModule> | undefined
|
|
207
|
+
if (service) loadExternal(service)
|
|
208
|
+
})
|
|
209
|
+
void (async () => {
|
|
210
|
+
try {
|
|
211
|
+
const userDir = join(process.env.DSH_PROFILE_DIR ?? join(process.env.HOME ?? '', '.dsh', 'profiles', 'web'), 'suppliers')
|
|
212
|
+
const { suppliers, errors } = await loadSuppliers({
|
|
213
|
+
builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
|
|
214
|
+
userDir,
|
|
215
|
+
dataDir,
|
|
216
|
+
store,
|
|
217
|
+
credentials,
|
|
218
|
+
log,
|
|
219
|
+
})
|
|
220
|
+
for (const err of errors) ctx.logger.warn(`[dsh-router] ${err.error}`)
|
|
221
|
+
for (const loaded of suppliers) registerLoaded(loaded)
|
|
222
|
+
log(`suppliers loaded: ${suppliers.map((s) => s.supplier.id).join(', ') || '(none)'}`)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
ctx.logger.warn(`[dsh-router] suppliers load failed: ${(err as Error).message}`)
|
|
225
|
+
}
|
|
226
|
+
})()
|
|
227
|
+
|
|
228
|
+
// ---- /v1/* (OpenAI-compatible, Bearer auth via KeysStore) ----
|
|
229
|
+
|
|
230
|
+
const withV1Auth = (handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>) => {
|
|
231
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
232
|
+
const authz = req.headers.authorization ?? ''
|
|
233
|
+
const prefix = 'Bearer '
|
|
234
|
+
const bearer = authz.length >= prefix.length && authz.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
|
|
235
|
+
? authz.slice(prefix.length)
|
|
236
|
+
: undefined
|
|
237
|
+
if (!keys.verify(bearer)) {
|
|
238
|
+
writeJson(res, 401, { error: { message: 'missing or invalid API key', type: 'api_error', code: 'invalid_api_key' } })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
await handler(req, res)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
route('/v1/models', withV1Auth(async (_req, res) => {
|
|
246
|
+
const models = await router.listModels()
|
|
247
|
+
writeJson(res, 200, { object: 'list', data: models })
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
route('/v1/chat/completions', withV1Auth(async (req, res) => {
|
|
251
|
+
let body: string
|
|
252
|
+
try {
|
|
253
|
+
body = await readBody(req, 8 << 20)
|
|
254
|
+
} catch {
|
|
255
|
+
writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
let peek: { stream?: boolean; model?: string }
|
|
259
|
+
try {
|
|
260
|
+
peek = JSON.parse(body) as { stream?: boolean; model?: string }
|
|
261
|
+
} catch {
|
|
262
|
+
writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
await router.chatCompletions(
|
|
266
|
+
{ rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '' },
|
|
267
|
+
res,
|
|
268
|
+
)
|
|
269
|
+
}))
|
|
270
|
+
|
|
271
|
+
// ---- /router/api/* (panel, same-origin) ----
|
|
272
|
+
|
|
273
|
+
route(`${ROUTER_API_BASE}/health`, (_req, res) => {
|
|
274
|
+
const { suppliers } = router.status()
|
|
275
|
+
writeJson(res, 200, {
|
|
276
|
+
ok: true,
|
|
277
|
+
suppliers: suppliers.map((s) => {
|
|
278
|
+
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
|
|
279
|
+
return {
|
|
280
|
+
id: s.id,
|
|
281
|
+
name: s.name,
|
|
282
|
+
icon: loaded?.supplier.icon,
|
|
283
|
+
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
284
|
+
source: loaded?.source ?? 'external',
|
|
285
|
+
}
|
|
286
|
+
}),
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
route(`${ROUTER_API_BASE}/status`, (_req, res) => {
|
|
291
|
+
const { suppliers } = router.status()
|
|
292
|
+
const accounts = suppliers.flatMap((s) =>
|
|
293
|
+
s.accounts.map((a) => ({ ...a, supplier: s.id })),
|
|
294
|
+
)
|
|
295
|
+
writeJson(res, 200, { ok: true, accounts })
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const models = await router.listModels()
|
|
301
|
+
writeJson(res, 200, { ok: true, models })
|
|
302
|
+
} catch (error) {
|
|
303
|
+
writeJson(res, 500, { ok: false, error: (error as Error).message })
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
route(`${ROUTER_API_BASE}/combos`, async (_req, res) => {
|
|
308
|
+
const [combos, groups] = await Promise.all([
|
|
309
|
+
router.combos(),
|
|
310
|
+
router.supplierModels().catch(() => []),
|
|
311
|
+
])
|
|
312
|
+
writeJson(res, 200, { ok: true, combos, groups, aliases: router.aliases() })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
|
|
316
|
+
let body: { name?: string; strategy?: string; models?: string[] }
|
|
317
|
+
try {
|
|
318
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; strategy?: string; models?: string[] }
|
|
319
|
+
} catch {
|
|
320
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
const result = router.createCombo(body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
|
|
324
|
+
writeJson(res, result.ok ? 200 : 400, result)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
|
|
328
|
+
let body: { id?: string; name?: string; strategy?: string; models?: string[] }
|
|
329
|
+
try {
|
|
330
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; strategy?: string; models?: string[] }
|
|
331
|
+
} catch {
|
|
332
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const result = router.updateCombo(body.id ?? '', body.name ?? '', body.strategy ?? 'fallback', body.models ?? [])
|
|
336
|
+
writeJson(res, result.ok ? 200 : 400, result)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
|
|
340
|
+
let body: { id?: string }
|
|
341
|
+
try {
|
|
342
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string }
|
|
343
|
+
} catch {
|
|
344
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const result = router.removeCombo(body.id ?? '')
|
|
348
|
+
writeJson(res, result.ok ? 200 : 400, result)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// ---- 端点与密钥:keys + settings ----
|
|
352
|
+
|
|
353
|
+
route(`${ROUTER_API_BASE}/keys`, async (req, res) => {
|
|
354
|
+
if (req.method === 'POST') {
|
|
355
|
+
let body: { name?: string }
|
|
356
|
+
try {
|
|
357
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string }
|
|
358
|
+
} catch {
|
|
359
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
const { key } = keys.create(typeof body.name === 'string' ? body.name : '')
|
|
363
|
+
writeJson(res, 201, { ok: true, key })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
writeJson(res, 200, { ok: true, keys: keys.list() })
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
route(`${ROUTER_API_BASE}/keys/delete`, async (req, res) => {
|
|
370
|
+
// exact route 无 path 参数:用 POST body {id}
|
|
371
|
+
let body: { id?: string }
|
|
372
|
+
try {
|
|
373
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string }
|
|
374
|
+
} catch {
|
|
375
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
const ok = typeof body.id === 'string' && keys.remove(body.id)
|
|
379
|
+
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'key not found' })
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
route(`${ROUTER_API_BASE}/keys/toggle`, async (req, res) => {
|
|
383
|
+
let body: { id?: string; isActive?: boolean }
|
|
384
|
+
try {
|
|
385
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; isActive?: boolean }
|
|
386
|
+
} catch {
|
|
387
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
const ok = typeof body.id === 'string' && keys.setActive(body.id, !!body.isActive)
|
|
391
|
+
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'key not found' })
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
route(`${ROUTER_API_BASE}/settings`, async (req, res) => {
|
|
395
|
+
if (req.method === 'PATCH') {
|
|
396
|
+
let body: { requireApiKey?: boolean }
|
|
397
|
+
try {
|
|
398
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { requireApiKey?: boolean }
|
|
399
|
+
} catch {
|
|
400
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (typeof body.requireApiKey === 'boolean') keys.requireApiKey = body.requireApiKey
|
|
404
|
+
}
|
|
405
|
+
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
|
|
409
|
+
|
|
410
|
+
// llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
|
|
411
|
+
if (ctx.llm !== undefined) {
|
|
412
|
+
const LLM_NS = 'llm-dsh-router'
|
|
413
|
+
try {
|
|
414
|
+
const providerReg = ctx.llm.registerConfigurableProviders([
|
|
415
|
+
{
|
|
416
|
+
provider: 'router',
|
|
417
|
+
displayName: 'Router',
|
|
418
|
+
settingsNs: LLM_NS,
|
|
419
|
+
settingsPath: [],
|
|
420
|
+
},
|
|
421
|
+
])
|
|
422
|
+
disposers.push(providerReg)
|
|
423
|
+
disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
|
|
424
|
+
const combos = await router.combos()
|
|
425
|
+
return combos.map((c) => ({ id: c.name }))
|
|
426
|
+
}))
|
|
427
|
+
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
428
|
+
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
|
|
429
|
+
comboModels: async () => (await router.combos()).map((c) => ({ id: c.name })),
|
|
430
|
+
})))
|
|
431
|
+
log('llm provider (Router) + discovery + adapter registered ok')
|
|
432
|
+
} catch (err) {
|
|
433
|
+
ctx.logger.warn(`[dsh-router] llm registration failed: ${(err as Error).message}`)
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
log('llm service NOT available — skip 设置-模型 registration')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 调试:Router provider 状态(设置-模型排查用)
|
|
440
|
+
route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
|
|
441
|
+
let directory: unknown = 'n/a'
|
|
442
|
+
let registeredProviders: unknown = 'n/a'
|
|
443
|
+
try {
|
|
444
|
+
directory = ctx.llm !== undefined ? ctx.llm.listConfigurableProviders() : 'no llm'
|
|
445
|
+
} catch (err) {
|
|
446
|
+
directory = `error: ${(err as Error).message}`
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
registeredProviders = ctx.llm !== undefined ? ctx.llm.listProviders() : 'no llm'
|
|
450
|
+
} catch (err) {
|
|
451
|
+
registeredProviders = `error: ${(err as Error).message}`
|
|
452
|
+
}
|
|
453
|
+
writeJson(res, 200, {
|
|
454
|
+
ok: true,
|
|
455
|
+
llmAvailable: ctx.llm !== undefined,
|
|
456
|
+
directory,
|
|
457
|
+
registeredProviders,
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
ctx.effect(
|
|
462
|
+
() => () => {
|
|
463
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
464
|
+
router.dispose()
|
|
465
|
+
},
|
|
466
|
+
'dsh-router: teardown',
|
|
467
|
+
)
|
|
468
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 密钥库 + 路由设置(requireApiKey)—— 9router「端点与密钥」核心。
|
|
3
|
+
* 持久化到 data/keys.json(与 pool 的 stateFile 同目录)。
|
|
4
|
+
*
|
|
5
|
+
* /v1/* 鉴权规则:
|
|
6
|
+
* - requireApiKey=false(默认)→ 不鉴权
|
|
7
|
+
* - requireApiKey=true → Bearer 必须是「启用的库内 key」或 TW2A_API_KEY env
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { API_KEY_REF } from './shared.ts'
|
|
13
|
+
|
|
14
|
+
/** 库内一条 key。 */
|
|
15
|
+
export interface ApiKeyEntry {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
/** 完整 key(仅创建时返回一次明文;列表返回脱敏)。 */
|
|
19
|
+
key: string
|
|
20
|
+
isActive: boolean
|
|
21
|
+
createdAt: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface KeysFile {
|
|
25
|
+
keys: ApiKeyEntry[]
|
|
26
|
+
requireApiKey: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function newId(): string {
|
|
30
|
+
return randomBytes(6).toString('hex')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function generateKey(): string {
|
|
34
|
+
return `dshr-${randomBytes(24).toString('hex')}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mask(k: string): string {
|
|
38
|
+
if (k.length <= 10) return k
|
|
39
|
+
return `${k.slice(0, 6)}${'•'.repeat(k.length - 10)}${k.slice(-4)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 密钥库。 */
|
|
43
|
+
export class KeysStore {
|
|
44
|
+
private fp = ''
|
|
45
|
+
private keys: ApiKeyEntry[] = []
|
|
46
|
+
private require = false
|
|
47
|
+
|
|
48
|
+
constructor(stateFile: string) {
|
|
49
|
+
this.fp = stateFile ? join(dirname(stateFile), 'keys.json') : ''
|
|
50
|
+
this.load()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private load(): void {
|
|
54
|
+
if (this.fp === '') return
|
|
55
|
+
let raw: string
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(this.fp, 'utf8')
|
|
58
|
+
} catch {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const f = JSON.parse(raw) as KeysFile
|
|
63
|
+
this.keys = Array.isArray(f.keys) ? f.keys : []
|
|
64
|
+
this.require = !!f.requireApiKey
|
|
65
|
+
} catch {
|
|
66
|
+
// 损坏则用空库
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private save(): void {
|
|
71
|
+
if (this.fp === '') return
|
|
72
|
+
try {
|
|
73
|
+
const dir = dirname(this.fp)
|
|
74
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
|
|
75
|
+
const raw = JSON.stringify({ keys: this.keys, requireApiKey: this.require }, null, 2)
|
|
76
|
+
const tmp = this.fp + '.tmp'
|
|
77
|
+
writeFileSync(tmp, raw, { mode: 0o600 })
|
|
78
|
+
renameSync(tmp, this.fp)
|
|
79
|
+
} catch {
|
|
80
|
+
// 持久化失败不阻断
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
list(): Array<{ id: string; name: string; key: string; masked: string; isActive: boolean; createdAt: string }> {
|
|
85
|
+
return this.keys.map((k) => ({
|
|
86
|
+
id: k.id,
|
|
87
|
+
name: k.name,
|
|
88
|
+
key: k.key,
|
|
89
|
+
masked: mask(k.key),
|
|
90
|
+
isActive: k.isActive,
|
|
91
|
+
createdAt: k.createdAt,
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 创建 key,返回明文(仅此一次)。 */
|
|
96
|
+
create(name: string): { entry: ApiKeyEntry; key: string } {
|
|
97
|
+
const entry: ApiKeyEntry = {
|
|
98
|
+
id: newId(),
|
|
99
|
+
name: name.trim() !== '' ? name.trim() : `Key ${this.keys.length + 1}`,
|
|
100
|
+
key: generateKey(),
|
|
101
|
+
isActive: true,
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
}
|
|
104
|
+
this.keys.push(entry)
|
|
105
|
+
this.save()
|
|
106
|
+
return { entry, key: entry.key }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
remove(id: string): boolean {
|
|
110
|
+
const before = this.keys.length
|
|
111
|
+
this.keys = this.keys.filter((k) => k.id !== id)
|
|
112
|
+
if (this.keys.length === before) return false
|
|
113
|
+
this.save()
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setActive(id: string, isActive: boolean): boolean {
|
|
118
|
+
const k = this.keys.find((k) => k.id === id)
|
|
119
|
+
if (!k) return false
|
|
120
|
+
k.isActive = isActive
|
|
121
|
+
this.save()
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** requireApiKey 开关。 */
|
|
126
|
+
get requireApiKey(): boolean {
|
|
127
|
+
return this.require
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
set requireApiKey(v: boolean) {
|
|
131
|
+
this.require = v
|
|
132
|
+
this.save()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 校验 Bearer key:requireApiKey 关 → 放行;开 → 库内启用 key 或 TW2A_API_KEY env。 */
|
|
136
|
+
verify(bearer: string | undefined): boolean {
|
|
137
|
+
if (!this.require) return true
|
|
138
|
+
if (bearer === undefined || bearer === '') return false
|
|
139
|
+
if (this.keys.some((k) => k.isActive && k.key === bearer)) return true
|
|
140
|
+
const envKey = process.env[API_KEY_REF]
|
|
141
|
+
return envKey !== undefined && envKey !== '' && envKey === bearer
|
|
142
|
+
}
|
|
143
|
+
}
|