dsh-router-core 0.4.3 → 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.
@@ -29,13 +29,13 @@ const PROMPT_CATEGORIES = [
29
29
  id: "closure",
30
30
  title: "全局收口",
31
31
  defaultOn: true,
32
- body: "收口(与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位;④前三问都空时处置一件存量(删/并/归档)。做完那块区域该比动手前更整或更少。"
32
+ body: "收口(只在任务完成时做,与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位。做完那块区域该比动手前更整或更少。"
33
33
  },
34
34
  {
35
35
  id: "hardening",
36
36
  title: "固化",
37
37
  defaultOn: true,
38
- body: "固化:每次造成用户可见损失的失败,当天固化为可自动重放的检查——会变红(注入违规必须失败)、可重放(一条命令)、归属清晰(失败信息指向违规处);面向他人的检查只警告不阻断。不固化的教训等于决定再犯一次。"
38
+ body: "固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。"
39
39
  },
40
40
  {
41
41
  id: "verdifiable",
package/lib/index.js CHANGED
@@ -4009,6 +4009,33 @@ function writeJson$1(res, status, body) {
4009
4009
  });
4010
4010
  res.end(payload);
4011
4011
  }
4012
+ /**
4013
+ * 包住会调插件的 handler:插件抛错时回 **JSON 500**,别让异常裸奔到 webServer。
4014
+ *
4015
+ * 为什么必须有这一层:webServer 的兜底是 `res.writeHead(400); res.end()`——**空
4016
+ * body**。面板拿到空 body,`response.json()` 抛 "Unexpected end of JSON input",
4017
+ * 真实原因(上游 500 / token 失效…)全丢,用户只看到一句解析错误。
4018
+ *
4019
+ * 这条路径是插件契约「出错就抛」(docs/suppliers.md「模型统一策略」) 的下游:插件
4020
+ * 老实抛了,核心就得老实接住并说清楚。以前 `listModels` 静默吞错,所以从没暴露过
4021
+ * 这个缺口(traework 2026-10-10 「获取模型只有几个」修完才浮现)。
4022
+ */
4023
+ function guard(handler) {
4024
+ return async (req, res) => {
4025
+ try {
4026
+ await handler(req, res);
4027
+ } catch (err) {
4028
+ if (res.headersSent) {
4029
+ res.end();
4030
+ return;
4031
+ }
4032
+ writeJson$1(res, err instanceof SyntaxError ? 400 : 500, {
4033
+ ok: false,
4034
+ error: err.message
4035
+ });
4036
+ }
4037
+ };
4038
+ }
4012
4039
  async function readBody$1(req, limit = 65536) {
4013
4040
  const chunks = [];
4014
4041
  let size = 0;
@@ -4067,14 +4094,14 @@ function supplierRoutes(base, loaded, store, router) {
4067
4094
  routes.push({
4068
4095
  kind: "exact",
4069
4096
  path: `${p}/models`,
4070
- handler: async (_req, res) => {
4097
+ handler: guard(async (_req, res) => {
4071
4098
  const models = await router.modelsOf(s.id);
4072
4099
  writeJson$1(res, 200, {
4073
4100
  ok: true,
4074
4101
  alias: s.getAlias(),
4075
4102
  models
4076
4103
  });
4077
- }
4104
+ })
4078
4105
  });
4079
4106
  routes.push({
4080
4107
  kind: "exact",
@@ -4202,7 +4229,7 @@ function supplierRoutes(base, loaded, store, router) {
4202
4229
  routes.push({
4203
4230
  kind: "exact",
4204
4231
  path: `${p}/models/toggle`,
4205
- handler: async (req, res) => {
4232
+ handler: guard(async (req, res) => {
4206
4233
  const body = JSON.parse(await readBody$1(req));
4207
4234
  store.get(s.id);
4208
4235
  const models = await router.modelsOf(s.id);
@@ -4216,12 +4243,12 @@ function supplierRoutes(base, loaded, store, router) {
4216
4243
  store.setModelEnabled(s.id, body.id, !!body.enabled);
4217
4244
  router.invalidateModels(s.id);
4218
4245
  writeJson$1(res, 200, { ok: true });
4219
- }
4246
+ })
4220
4247
  });
4221
4248
  routes.push({
4222
4249
  kind: "exact",
4223
4250
  path: `${p}/models/add`,
4224
- handler: async (req, res) => {
4251
+ handler: guard(async (req, res) => {
4225
4252
  const id = (JSON.parse(await readBody$1(req)).id ?? "").trim();
4226
4253
  if (id === "") {
4227
4254
  writeJson$1(res, 400, {
@@ -4240,7 +4267,7 @@ function supplierRoutes(base, loaded, store, router) {
4240
4267
  store.addCustomModel(s.id, id);
4241
4268
  router.invalidateModels(s.id);
4242
4269
  writeJson$1(res, 200, { ok: true });
4243
- }
4270
+ })
4244
4271
  });
4245
4272
  routes.push({
4246
4273
  kind: "exact",
@@ -4263,13 +4290,13 @@ function supplierRoutes(base, loaded, store, router) {
4263
4290
  routes.push({
4264
4291
  kind: "exact",
4265
4292
  path: `${p}/models/bulk`,
4266
- handler: async (req, res) => {
4293
+ handler: guard(async (req, res) => {
4267
4294
  const body = JSON.parse(await readBody$1(req));
4268
4295
  const models = await router.modelsOf(s.id);
4269
4296
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id));
4270
4297
  router.invalidateModels(s.id);
4271
4298
  writeJson$1(res, 200, { ok: true });
4272
- }
4299
+ })
4273
4300
  });
4274
4301
  routes.push({
4275
4302
  kind: "exact",
@@ -4297,12 +4324,12 @@ function supplierRoutes(base, loaded, store, router) {
4297
4324
  routes.push({
4298
4325
  kind: "exact",
4299
4326
  path: `${p}/models/fetch`,
4300
- handler: async (_req, res) => {
4327
+ handler: guard(async (_req, res) => {
4301
4328
  writeJson$1(res, 200, {
4302
4329
  ok: true,
4303
4330
  models: await router.modelsOf(s.id, true)
4304
4331
  });
4305
- }
4332
+ })
4306
4333
  });
4307
4334
  routes.push({
4308
4335
  kind: "exact",
@@ -1,18 +1,25 @@
1
1
  /**
2
- * 提示词内容层 —— 准则 v5 的**逐字**副本,按原分隔拆成分类。
2
+ * 提示词内容层 —— 准则 v5 的副本,按原分隔拆成分类。
3
3
  *
4
4
  * ════════════════════════════════════════════════════════════════════
5
- * 为什么一字不改
5
+ * 为什么原则上不改措辞
6
6
  * ════════════════════════════════════════════════════════════════════
7
7
  * 这些字是**用户自己写的准则**,不是本插件的文案。任何"顺手润色"都是擅自
8
8
  * 改写用户的规则 —— 改出来的版本和用户心里的版本不一致,而用户看不见差异,
9
- * 于是准则开始按**另一份**文本执行。所以这里是逐字复制,**不做任何措辞优化**。
9
+ * 于是准则开始按**另一份**文本执行。所以改动只在**用户点名**时发生,不做
10
+ * 未被要求的措辞优化。
10
11
  *
11
12
  * 拆分只为「可开关」,不为了重排:顺序与原文出现顺序一致,`order` 也按此递增。
12
13
  *
13
14
  * ⚠️ **本文件是唯一事实源**。准则原本存放在 Mnemon 记忆系统的 `USER.md`;
14
15
  * 那份已经在插件验证生效后删除(否则新旧两份注入,每轮白烧一次 token 且
15
16
  * 永远有两份可能漂移的规则)。**改动准则 = 只改这里。**
17
+ *
18
+ * ⚠️ 2026-10-08 用户点名的两处改动(治「提示词导致思考过久」):
19
+ * ① 收口:加"只在任务完成时做",并**删掉第 ④ 条**「前三问都空时处置一件存量」
20
+ * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
+ * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
+ * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
16
23
  */
17
24
  /** 一个可独立开关的提示词分类。 */
18
25
  export interface PromptCategory {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,18 +1,25 @@
1
1
  /**
2
- * 提示词内容层 —— 准则 v5 的**逐字**副本,按原分隔拆成分类。
2
+ * 提示词内容层 —— 准则 v5 的副本,按原分隔拆成分类。
3
3
  *
4
4
  * ════════════════════════════════════════════════════════════════════
5
- * 为什么一字不改
5
+ * 为什么原则上不改措辞
6
6
  * ════════════════════════════════════════════════════════════════════
7
7
  * 这些字是**用户自己写的准则**,不是本插件的文案。任何"顺手润色"都是擅自
8
8
  * 改写用户的规则 —— 改出来的版本和用户心里的版本不一致,而用户看不见差异,
9
- * 于是准则开始按**另一份**文本执行。所以这里是逐字复制,**不做任何措辞优化**。
9
+ * 于是准则开始按**另一份**文本执行。所以改动只在**用户点名**时发生,不做
10
+ * 未被要求的措辞优化。
10
11
  *
11
12
  * 拆分只为「可开关」,不为了重排:顺序与原文出现顺序一致,`order` 也按此递增。
12
13
  *
13
14
  * ⚠️ **本文件是唯一事实源**。准则原本存放在 Mnemon 记忆系统的 `USER.md`;
14
15
  * 那份已经在插件验证生效后删除(否则新旧两份注入,每轮白烧一次 token 且
15
16
  * 永远有两份可能漂移的规则)。**改动准则 = 只改这里。**
17
+ *
18
+ * ⚠️ 2026-10-08 用户点名的两处改动(治「提示词导致思考过久」):
19
+ * ① 收口:加"只在任务完成时做",并**删掉第 ④ 条**「前三问都空时处置一件存量」
20
+ * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
+ * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
+ * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
16
23
  */
17
24
 
18
25
  /** 一个可独立开关的提示词分类。 */
@@ -69,8 +76,8 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
69
76
  title: '全局收口',
70
77
  defaultOn: true,
71
78
  body:
72
- '收口(与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;' +
73
- '②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位;④前三问都空时处置一件存量(删/并/归档)。' +
79
+ '收口(只在任务完成时做,与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;' +
80
+ '②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位。' +
74
81
  '做完那块区域该比动手前更整或更少。',
75
82
  },
76
83
  {
@@ -78,8 +85,8 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
78
85
  title: '固化',
79
86
  defaultOn: true,
80
87
  body:
81
- '固化:每次造成用户可见损失的失败,当天固化为可自动重放的检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
82
- '归属清晰(失败信息指向违规处);面向他人的检查只警告不阻断。不固化的教训等于决定再犯一次。',
88
+ '固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
89
+ '归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。',
83
90
  },
84
91
  {
85
92
  id: 'verdifiable',
@@ -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 路径,账号池回退/冷却自动生效) ----