dsh-router-core 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -10
- package/cordis.patch.yml +10 -0
- package/docs/ext.md +64 -2
- package/lib/client-registry.js +633 -50
- package/lib/client.js +633 -50
- package/lib/contract-D4BWZ7Xe.js +13 -0
- package/lib/ext-prompt/index.js +527 -0
- package/lib/ext-prompt/locale/en.json +6 -0
- package/lib/ext-prompt/locale/zh.json +6 -0
- package/lib/ext-prompt/package.json +5 -0
- package/lib/ext-test/index.js +1 -12
- package/lib/index.js +286 -127
- package/lib/types/ext/contract.d.ts +113 -0
- package/lib/types/ext-prompt/content.d.ts +64 -0
- package/lib/types/ext-prompt/index.d.ts +53 -0
- package/lib/types/ext-prompt/logo.d.ts +17 -0
- package/lib/types/ext-prompt/mount.d.ts +51 -0
- package/lib/types/ext-prompt/plugin.d.ts +32 -0
- package/lib/types/ext-prompt/render.d.ts +105 -0
- package/lib/types/host-version.d.ts +7 -8
- package/lib/types/index.d.ts +1 -1
- package/lib/types/shared.d.ts +33 -0
- package/package.json +17 -7
- package/src/client/EndpointTab.tsx +15 -18
- package/src/client/ExtDetail.tsx +552 -9
- package/src/client/index.tsx +7 -28
- package/src/client/router.css +132 -62
- package/src/ext/contract.ts +111 -0
- package/src/ext-prompt/assembly.test.ts +79 -0
- package/src/ext-prompt/content.ts +181 -0
- package/src/ext-prompt/ext-prompt.test.ts +432 -0
- package/src/ext-prompt/index.ts +104 -0
- package/src/ext-prompt/locale/en.json +6 -0
- package/src/ext-prompt/locale/zh.json +6 -0
- package/src/ext-prompt/logo.svg +14 -0
- package/src/ext-prompt/logo.ts +18 -0
- package/src/ext-prompt/mount.test.ts +95 -0
- package/src/ext-prompt/mount.ts +79 -0
- package/src/ext-prompt/plugin.ts +223 -0
- package/src/ext-prompt/render.test.ts +256 -0
- package/src/ext-prompt/render.ts +216 -0
- package/src/ext-prompt/row.json +5 -0
- package/src/ext-route.test.ts +78 -0
- package/src/host-version.test.ts +21 -28
- package/src/host-version.ts +39 -39
- package/src/index.ts +216 -94
- package/src/llm/adapter.test.ts +19 -42
- package/src/llm/adapter.ts +20 -47
- package/src/shared.ts +34 -0
- package/src/suppliers/registry.test.ts +56 -0
- package/src/suppliers/registry.ts +40 -10
package/src/shared.ts
CHANGED
|
@@ -164,6 +164,40 @@ export interface RouterExtItem {
|
|
|
164
164
|
* 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
|
|
165
165
|
*/
|
|
166
166
|
source?: 'builtin'
|
|
167
|
+
/**
|
|
168
|
+
* 扩展自带的子开关(如准则的每一条)。**由扩展自报**,核心只搬运。
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ 与 `enabled`(总开关)不同:子开关**不改变扩展是否被加载**,只改它
|
|
171
|
+
* 自己的行为。总开关关时这些子开关一并失效(面板上禁用,不隐藏)。
|
|
172
|
+
*/
|
|
173
|
+
controls?: ExtControlItem[]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `/router/api/ext` 里的一条子开关(与 `router.ext` 契约的 `ExtControl` 对应)。 */
|
|
177
|
+
export interface ExtControlItem {
|
|
178
|
+
/** 稳定 id(持久化键;改名会丢用户已保存的选择)。 */
|
|
179
|
+
id: string
|
|
180
|
+
title: string
|
|
181
|
+
/** 当前是否开启(扩展自算:默认值 + 用户已存选择)。 */
|
|
182
|
+
on: boolean
|
|
183
|
+
/** 一句话说明(面板副行,可选)。 */
|
|
184
|
+
detail?: string
|
|
185
|
+
/** 原文(面板点开即可读到;不给出就是在盲切,见 `ExtControl.body`)。 */
|
|
186
|
+
body?: string
|
|
187
|
+
/** 文本可编辑(缺省 = 只读;见 `ExtControl.editable`)。 */
|
|
188
|
+
editable?: boolean
|
|
189
|
+
/**
|
|
190
|
+
* 自建条目(可删)还是内置(不可删、只有开关与还原)。
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ **必填,不给可选**(2026-09-30 实测踩过):曾声明成 `custom?: boolean`,
|
|
193
|
+
* 而服务端只在 `custom === true` 时才带这个字段 ⇒ 内置条目拿到的是
|
|
194
|
+
* `undefined`,面板判 `c.custom === true` 为假,**恰好是对的** ——
|
|
195
|
+
* 但那条正确性建立在"缺省恰好等于 false"上,改任何一边的默认值都会翻转。
|
|
196
|
+
* ⇒ 让服务端**总是**给显式布尔,两边不必猜。
|
|
197
|
+
*/
|
|
198
|
+
custom: boolean
|
|
199
|
+
/** 文本被改过(决定「还原」是否可点)。必填,理由同 `custom`。 */
|
|
200
|
+
overridden: boolean
|
|
167
201
|
}
|
|
168
202
|
|
|
169
203
|
/** `/router/api/ext` response(扩展插件列表 + 开关)。 */
|
|
@@ -211,3 +211,59 @@ test('刷新:status() 抛错 → 500 带错误信息', async () => {
|
|
|
211
211
|
assert.equal(body.ok, false)
|
|
212
212
|
assert.match(body.error ?? '', /status boom/)
|
|
213
213
|
})
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 插件在 `listModels` 里抛错时,核心必须回 **JSON 500**。
|
|
217
|
+
*
|
|
218
|
+
* 为什么值得单锁:webServer 的兜底是 `res.writeHead(400); res.end()` —— **空
|
|
219
|
+
* body**。面板 `response.json()` 于是抛 "Unexpected end of JSON input",真实原因
|
|
220
|
+
* (上游 500 / token 失效…)全丢。插件契约要求「出错就抛」(docs/suppliers.md),
|
|
221
|
+
* 所以这层必须接住。traework 修掉 `catch {}` 之后这条才第一次真被走到。
|
|
222
|
+
*/
|
|
223
|
+
test('models 路由:插件 listModels 抛错 → 500 + JSON body(不是空 400)', async () => {
|
|
224
|
+
for (const path of ['models', 'models/fetch']) {
|
|
225
|
+
const supplier = {
|
|
226
|
+
id: 'fake',
|
|
227
|
+
name: 'fake',
|
|
228
|
+
status: () => ({ id: 'fake', name: 'fake', accounts: [] }),
|
|
229
|
+
}
|
|
230
|
+
const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
|
|
231
|
+
// router.modelsOf 复刻核心语义:无旧值 → 把插件的错抛出来
|
|
232
|
+
const router = {
|
|
233
|
+
modelsOf: async (): Promise<never> => { throw new Error('models api returned empty list') },
|
|
234
|
+
invalidateModels: () => {},
|
|
235
|
+
}
|
|
236
|
+
const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
|
|
237
|
+
const route = routes.find((r) => r.path === `/router/api/suppliers/fake/${path}`)
|
|
238
|
+
assert.ok(route, `${path} 路由应注册`)
|
|
239
|
+
const sink = fakeRes()
|
|
240
|
+
await route!.handler({} as never, sink.res)
|
|
241
|
+
const body = sink.body as { ok: boolean; error?: string }
|
|
242
|
+
assert.equal(sink.status, 500, `${path}: 抛错该回 500`)
|
|
243
|
+
assert.equal(body.ok, false, `${path}: 必须是 JSON body,不能是空 body`)
|
|
244
|
+
assert.match(String(body.error), /empty list/, `${path}: 真实原因不能丢`)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('models 路由:body 不是合法 JSON → 400(不是 500,也不是空 body)', async () => {
|
|
249
|
+
const supplier = { id: 'fake', name: 'fake', status: () => ({ id: 'fake', name: 'fake', accounts: [] }) }
|
|
250
|
+
const loaded = { supplier, capabilities: new Set(), source: 'builtin' }
|
|
251
|
+
const router = { modelsOf: async () => [], invalidateModels: () => {} }
|
|
252
|
+
const routes = supplierRoutes('/router/api', loaded as never, { get: () => ({ custom: [], disabled: [], poolOrder: [], credits: {} }) } as never, router as never)
|
|
253
|
+
const route = routes.find((r) => r.path === '/router/api/suppliers/fake/models/toggle')
|
|
254
|
+
assert.ok(route)
|
|
255
|
+
// 造一个会吐非法 JSON 的 req(readBody 读 data/end)
|
|
256
|
+
const req = {
|
|
257
|
+
on(event: string, cb: (chunk?: Buffer) => void) {
|
|
258
|
+
if (event === 'data') cb(Buffer.from('{ not json'))
|
|
259
|
+
if (event === 'end') cb()
|
|
260
|
+
return req
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
const sink = fakeRes()
|
|
264
|
+
await route!.handler(req as never, sink.res)
|
|
265
|
+
const body = sink.body as { ok: boolean; error?: string }
|
|
266
|
+
assert.equal(sink.status, 400, '客户端传坏 body 是 400,不是核心 500')
|
|
267
|
+
assert.equal(body.ok, false)
|
|
268
|
+
assert.ok(String(body.error).length > 0, '400 也要带原因,不能是空 body')
|
|
269
|
+
})
|
|
@@ -44,6 +44,36 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
|
44
44
|
res.end(payload)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
|
|
49
|
+
*
|
|
50
|
+
* 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
|
|
51
|
+
* body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
|
|
52
|
+
* 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
|
|
53
|
+
*
|
|
54
|
+
* 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
|
|
55
|
+
* 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
|
|
56
|
+
* 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
|
|
57
|
+
*/
|
|
58
|
+
function guard(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>) {
|
|
59
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
60
|
+
try {
|
|
61
|
+
await handler(req, res)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// headers 已发出就没法再改状态码了(writeJson 是原子的,这里基本不会发生)
|
|
64
|
+
if (res.headersSent) {
|
|
65
|
+
res.end()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
// body 不是合法 JSON 是客户端的事(400),不该报成核心 500。这些 handler 里
|
|
69
|
+
// 唯一的 SyntaxError 来源就是 `JSON.parse(await readBody(req))`。
|
|
70
|
+
// (改 guard 之前这种情况由 webServer 兜底成**空** 400,改后保住状态码又带上原因。)
|
|
71
|
+
const status = err instanceof SyntaxError ? 400 : 500
|
|
72
|
+
writeJson(res, status, { ok: false, error: (err as Error).message })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
async function readBody(req: IncomingMessage, limit = 64 << 10): Promise<string> {
|
|
48
78
|
const chunks: Buffer[] = []
|
|
49
79
|
let size = 0
|
|
@@ -109,10 +139,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
109
139
|
routes.push({
|
|
110
140
|
kind: 'exact',
|
|
111
141
|
path: `${p}/models`,
|
|
112
|
-
handler: async (_req, res) => {
|
|
142
|
+
handler: guard(async (_req, res) => {
|
|
113
143
|
const models = await router.modelsOf(s.id)
|
|
114
144
|
writeJson(res, 200, { ok: true, alias: s.getAlias(), models })
|
|
115
|
-
},
|
|
145
|
+
}),
|
|
116
146
|
})
|
|
117
147
|
|
|
118
148
|
// ---- 通用: 供应商开关 ----
|
|
@@ -235,7 +265,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
235
265
|
routes.push({
|
|
236
266
|
kind: 'exact',
|
|
237
267
|
path: `${p}/models/toggle`,
|
|
238
|
-
handler: async (req, res) => {
|
|
268
|
+
handler: guard(async (req, res) => {
|
|
239
269
|
const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
|
|
240
270
|
const cfg = store.get(s.id)
|
|
241
271
|
const models = await router.modelsOf(s.id)
|
|
@@ -246,14 +276,14 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
246
276
|
store.setModelEnabled(s.id, body.id, !!body.enabled)
|
|
247
277
|
router.invalidateModels(s.id)
|
|
248
278
|
writeJson(res, 200, { ok: true })
|
|
249
|
-
},
|
|
279
|
+
}),
|
|
250
280
|
})
|
|
251
281
|
|
|
252
282
|
// ---- 通用: models/add(自定义模型) ----
|
|
253
283
|
routes.push({
|
|
254
284
|
kind: 'exact',
|
|
255
285
|
path: `${p}/models/add`,
|
|
256
|
-
handler: async (req, res) => {
|
|
286
|
+
handler: guard(async (req, res) => {
|
|
257
287
|
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
258
288
|
const id = (body.id ?? '').trim()
|
|
259
289
|
if (id === '') {
|
|
@@ -268,7 +298,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
268
298
|
store.addCustomModel(s.id, id)
|
|
269
299
|
router.invalidateModels(s.id)
|
|
270
300
|
writeJson(res, 200, { ok: true })
|
|
271
|
-
},
|
|
301
|
+
}),
|
|
272
302
|
})
|
|
273
303
|
|
|
274
304
|
// ---- 通用: models/remove(自定义模型) ----
|
|
@@ -292,13 +322,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
292
322
|
routes.push({
|
|
293
323
|
kind: 'exact',
|
|
294
324
|
path: `${p}/models/bulk`,
|
|
295
|
-
handler: async (req, res) => {
|
|
325
|
+
handler: guard(async (req, res) => {
|
|
296
326
|
const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
|
|
297
327
|
const models = await router.modelsOf(s.id)
|
|
298
328
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
|
|
299
329
|
router.invalidateModels(s.id)
|
|
300
330
|
writeJson(res, 200, { ok: true })
|
|
301
|
-
},
|
|
331
|
+
}),
|
|
302
332
|
})
|
|
303
333
|
|
|
304
334
|
// ---- 通用: pool/order ----
|
|
@@ -324,10 +354,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
324
354
|
routes.push({
|
|
325
355
|
kind: 'exact',
|
|
326
356
|
path: `${p}/models/fetch`,
|
|
327
|
-
handler: async (_req, res) => {
|
|
357
|
+
handler: guard(async (_req, res) => {
|
|
328
358
|
const models = await router.modelsOf(s.id, true)
|
|
329
359
|
writeJson(res, 200, { ok: true, models })
|
|
330
|
-
},
|
|
360
|
+
}),
|
|
331
361
|
})
|
|
332
362
|
|
|
333
363
|
// ---- 通用: 测试模型(核心统一走 chatOnce 路径,账号池回退/冷却自动生效) ----
|