dsh-router-core 0.1.0 → 0.2.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.
@@ -17,11 +17,12 @@
17
17
  * POST /suppliers/:id/login 生成登录链接
18
18
  * POST /suppliers/:id/login/callback {callbackUrl}
19
19
  * POST /suppliers/:id/links/remove {uid}
20
- * GET /suppliers/:id/checkin/rule + POST {rule}
21
- * POST /suppliers/:id/checkin 触发签到
20
+ * POST /suppliers/:id/links/refresh 刷新链接池(积分,核心调 status() 等落地)
21
+ * POST /suppliers/:id/checkin 签到所有链接(核心遍历 + 汇总)
22
22
  */
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http'
24
- import type { ModelWithEnabled } from '../router/types.ts'
24
+ import type { Router } from '../router/index.ts'
25
+ import type { ModelWithEnabled, SupplierStatus } from '../router/types.ts'
25
26
  import type { SupplierConfigStore } from '../supplier-config.ts'
26
27
  import type { LoadedSupplier } from './loader.ts'
27
28
 
@@ -63,32 +64,31 @@ function cap(set: Set<string>, key: string): boolean {
63
64
  return set.has(key)
64
65
  }
65
66
 
66
- /** 模型列表统一缓存(dsh-router 核心管;插件只管拉取,不缓存)。 */
67
- const modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
68
- const MODELS_TTL_MS = 60 * 1000
67
+ /** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
68
+ function fingerprint(accounts: SupplierStatus['accounts']): string {
69
+ return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}:${a.disabled}`).join('|')
70
+ }
69
71
 
70
- /** 拉取并缓存某供应商模型(force 时强制刷新)。custom 模型并入显示。 */
71
- async function cachedModels(s: LoadedSupplier['supplier'], cfg: ReturnType<SupplierConfigStore['get']>, force = false): Promise<ModelWithEnabled[]> {
72
- const hit = modelsCache.get(s.id)
73
- if (!force && hit && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
74
- const list = await Promise.resolve(s.listModels())
75
- const custom = new Set(cfg.custom)
76
- const disabled = new Set(cfg.disabled)
77
- const models: ModelWithEnabled[] = list.map((mm) => ({
78
- ...mm,
79
- enabled: !disabled.has(mm.id),
80
- custom: custom.has(mm.id) ? true : undefined,
81
- }))
82
- // 自定义模型(listModels 之外的)并入显示
83
- const seen = new Set(models.map((mm) => mm.id))
84
- for (const id of custom) {
85
- if (!seen.has(id)) {
86
- seen.add(id)
87
- models.push({ id, enabled: !disabled.has(id), custom: true })
88
- }
72
+ /**
73
+ * 反复调插件已有的 status(),直到快照稳定再返回(是否有变化 = changed)。
74
+ * 插件的积分刷新是 fire-and-forget(status() 内部异步拉),核心拿不到句柄,
75
+ * 只能按指纹变化等它落地。天花板:缓存未过期时插件不会真拉上游,此时按钮
76
+ * 退化为「重读一次状态」(冷却/禁用这类健康状态仍是 status() 实时算的)。
77
+ * 升级路径:插件暴露可 await 的刷新能力,这里就不用轮询了。
78
+ */
79
+ async function settleStatus(s: LoadedSupplier['supplier'], timeoutMs = 3000): Promise<boolean> {
80
+ const first = fingerprint(s.status().accounts)
81
+ const deadline = Date.now() + timeoutMs
82
+ let last = first
83
+ let stable = 0
84
+ while (Date.now() < deadline) {
85
+ await new Promise((resolve) => setTimeout(resolve, 200))
86
+ const now = fingerprint(s.status().accounts)
87
+ stable = now === last ? stable + 1 : 0
88
+ last = now
89
+ if (stable >= 2) break // 连续两次没变 → 认为刷新已落地
89
90
  }
90
- modelsCache.set(s.id, { models, fetchedAt: Date.now() })
91
- return models
91
+ return last !== first
92
92
  }
93
93
 
94
94
  function mod(s: LoadedSupplier): Record<string, unknown> {
@@ -96,7 +96,7 @@ function mod(s: LoadedSupplier): Record<string, unknown> {
96
96
  }
97
97
 
98
98
  /** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
99
- export function supplierRoutes(base: string, loaded: LoadedSupplier, store: SupplierConfigStore): WebServerRoute[] {
99
+ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: SupplierConfigStore, router: Router): WebServerRoute[] {
100
100
  const s = loaded.supplier
101
101
  const m = mod(loaded)
102
102
  const c = loaded.capabilities
@@ -108,7 +108,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
108
108
  kind: 'exact',
109
109
  path: `${p}/models`,
110
110
  handler: async (_req, res) => {
111
- const models = await cachedModels(s, store.get(s.id))
111
+ const models = await router.modelsOf(s.id)
112
112
  writeJson(res, 200, { ok: true, alias: s.getAlias(), models })
113
113
  },
114
114
  })
@@ -137,13 +137,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
137
137
  handler: async (req, res) => {
138
138
  const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
139
139
  const cfg = store.get(s.id)
140
- const models = await cachedModels(s, cfg)
140
+ const models = await router.modelsOf(s.id)
141
141
  if (!body.id || !models.some((mm) => mm.id === body.id)) {
142
142
  writeJson(res, 400, { ok: false, error: '模型不存在' })
143
143
  return
144
144
  }
145
145
  store.setModelEnabled(s.id, body.id, !!body.enabled)
146
- modelsCache.delete(s.id) // 启用状态变化后失效缓存
146
+ router.invalidateModels(s.id)
147
147
  writeJson(res, 200, { ok: true })
148
148
  },
149
149
  })
@@ -159,13 +159,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
159
159
  writeJson(res, 400, { ok: false, error: '模型 id 不能为空' })
160
160
  return
161
161
  }
162
- const models = await cachedModels(s, store.get(s.id))
162
+ const models = await router.modelsOf(s.id)
163
163
  if (models.some((mm) => mm.id === id)) {
164
164
  writeJson(res, 400, { ok: false, error: `模型 ${id} 已存在` })
165
165
  return
166
166
  }
167
167
  store.addCustomModel(s.id, id)
168
- modelsCache.delete(s.id)
168
+ router.invalidateModels(s.id)
169
169
  writeJson(res, 200, { ok: true })
170
170
  },
171
171
  })
@@ -182,7 +182,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
182
182
  return
183
183
  }
184
184
  store.removeCustomModel(s.id, body.id)
185
- modelsCache.delete(s.id)
185
+ router.invalidateModels(s.id)
186
186
  writeJson(res, 200, { ok: true })
187
187
  },
188
188
  })
@@ -193,9 +193,9 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
193
193
  path: `${p}/models/bulk`,
194
194
  handler: async (req, res) => {
195
195
  const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
196
- const models = await cachedModels(s, store.get(s.id))
196
+ const models = await router.modelsOf(s.id)
197
197
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
198
- modelsCache.delete(s.id)
198
+ router.invalidateModels(s.id)
199
199
  writeJson(res, 200, { ok: true })
200
200
  },
201
201
  })
@@ -242,18 +242,18 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
242
242
  kind: 'exact',
243
243
  path: `${p}/models/fetch`,
244
244
  handler: async (_req, res) => {
245
- const models = await cachedModels(s, store.get(s.id), true)
245
+ const models = await router.modelsOf(s.id, true)
246
246
  writeJson(res, 200, { ok: true, models })
247
247
  },
248
248
  })
249
249
 
250
- // ---- 必要差异化: testModel(所有供应商必须实现) ----
250
+ // ---- 通用: 测试模型(核心统一走 chatOnce 路径,账号池回退/冷却自动生效) ----
251
251
  routes.push({
252
252
  kind: 'exact',
253
253
  path: `${p}/models/test`,
254
254
  handler: async (req, res) => {
255
255
  const body = JSON.parse(await readBody(req)) as { id?: string }
256
- const result = await s.testModel(body.id ?? '')
256
+ const result = await router.testModel(s.id, body.id ?? '')
257
257
  writeJson(res, result.ok ? 200 : 400, result)
258
258
  },
259
259
  })
@@ -327,22 +327,19 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
327
327
  },
328
328
  })
329
329
 
330
- // ---- 通用: 签到规则(dsh-router 策略,存 SupplierConfigStore) ----
330
+ // ---- 通用: 刷新链接池(积分 + 健康) ----
331
+ // 刷新是核心的活,但只调插件已有的 status()——插件在 status() 里自带积分
332
+ // 异步刷新(缓存过期才真拉上游),冷却/禁用(健康)也是 status() 实时算的。
331
333
  routes.push({
332
334
  kind: 'exact',
333
- path: `${p}/checkin/rule`,
334
- handler: async (req, res) => {
335
- if (req.method === 'GET') {
336
- writeJson(res, 200, { ok: true, rule: store.get(s.id).checkinRule })
337
- return
338
- }
339
- const body = JSON.parse(await readBody(req)) as { rule?: string }
340
- if (body.rule !== 'all' && body.rule !== 'first') {
341
- writeJson(res, 400, { ok: false, error: 'rule 必须是 all 或 first' })
342
- return
335
+ path: `${p}/links/refresh`,
336
+ handler: async (_req, res) => {
337
+ try {
338
+ const changed = await settleStatus(s)
339
+ writeJson(res, 200, { ok: true, changed, accounts: s.status().accounts })
340
+ } catch (err) {
341
+ writeJson(res, 500, { ok: false, error: (err as Error).message })
343
342
  }
344
- store.setCheckinRule(s.id, body.rule)
345
- writeJson(res, 200, { ok: true })
346
343
  },
347
344
  })
348
345
 
@@ -351,12 +348,30 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
351
348
  kind: 'exact',
352
349
  path: `${p}/checkin`,
353
350
  handler: async (_req, res) => {
354
- try {
355
- const result = await (m.checkinNow as () => Promise<{ ok: boolean; error?: string }>).call(loaded.supplier)
356
- writeJson(res, result.ok === false ? 400 : 200, result)
357
- } catch (err) {
358
- writeJson(res, 500, { ok: false, error: (err as Error).message })
351
+ // 签到 = 所有链接逐个签一次:checkinNow 是单账号能力,
352
+ // 遍历与汇总是核心的活(插件不负责连接池顺序/范围)。
353
+ // 禁用与否由插件自己判定(如 traework 返回 status:'disabled'),核心不替它筛。
354
+ const checkinOne = m.checkinNow as
355
+ ((uid: string) => Promise<{ ok: boolean; status: string; message?: string }>) | undefined
356
+ if (!checkinOne) {
357
+ writeJson(res, 400, { ok: false, error: '该供应商不支持签到' })
358
+ return
359
+ }
360
+ type CheckinResult = { uid: string; ok: boolean; status: string; message?: string }
361
+ const uids = s.status().accounts.map((a) => a.uid)
362
+ const results: CheckinResult[] = []
363
+ // 单个链接抛错不能带垮整批:记成 error 继续下一个
364
+ for (const uid of uids) {
365
+ try {
366
+ results.push({ uid, ...(await checkinOne.call(loaded.supplier, uid)) })
367
+ } catch (err) {
368
+ results.push({ uid, ok: false, status: 'error', message: (err as Error).message })
369
+ }
359
370
  }
371
+ const succeeded = results.filter((r) => r.status === 'ok').length
372
+ const already = results.filter((r) => r.status === 'already').length
373
+ const payload = { ok: succeeded + already > 0, total: uids.length, succeeded, already, results }
374
+ writeJson(res, payload.ok ? 200 : 400, payload)
360
375
  },
361
376
  })
362
377
  }