dsh-router-core 0.1.0 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +58 -13
  2. package/lib/client-registry.js +824 -153
  3. package/lib/client.js +824 -153
  4. package/lib/index.js +1606 -227
  5. package/lib/types/llm/adapter.d.ts +2 -0
  6. package/lib/types/router/account-pool.d.ts +41 -0
  7. package/lib/types/router/index.d.ts +72 -4
  8. package/lib/types/router/types.d.ts +12 -10
  9. package/lib/types/router/usage-store.d.ts +93 -0
  10. package/lib/types/router/usage-tokens.d.ts +118 -0
  11. package/lib/types/shared.d.ts +69 -0
  12. package/lib/types/supplier-config.d.ts +15 -4
  13. package/lib/types/suppliers/contract.d.ts +70 -21
  14. package/lib/types/suppliers/registry.d.ts +4 -3
  15. package/package.json +2 -1
  16. package/src/client/CombosTab.tsx +28 -16
  17. package/src/client/RouterView.tsx +15 -2
  18. package/src/client/StatsTab.tsx +521 -0
  19. package/src/client/SupplierDetail.tsx +58 -34
  20. package/src/client/router.css +406 -0
  21. package/src/client/workspace-mount.tsx +2 -9
  22. package/src/index.ts +52 -4
  23. package/src/llm/adapter.test.ts +108 -0
  24. package/src/llm/adapter.ts +18 -6
  25. package/src/router/account-pool.test.ts +120 -0
  26. package/src/router/account-pool.ts +161 -0
  27. package/src/router/index.ts +652 -26
  28. package/src/router/models-cache.test.ts +455 -0
  29. package/src/router/precise-dispatch.test.ts +198 -0
  30. package/src/router/types.ts +12 -7
  31. package/src/router/usage-store.ts +499 -0
  32. package/src/router/usage-tokens.ts +327 -0
  33. package/src/router/usage.test.ts +558 -0
  34. package/src/shared.ts +77 -0
  35. package/src/supplier-config.ts +31 -14
  36. package/src/suppliers/contract.ts +71 -8
  37. package/src/suppliers/loader.ts +21 -13
  38. package/src/suppliers/nvidia/plugin.ts +161 -119
  39. package/src/suppliers/opencode/plugin.ts +47 -95
  40. package/src/suppliers/openrouter/plugin.ts +67 -122
  41. package/src/suppliers/registry.test.ts +194 -0
  42. package/src/suppliers/registry.ts +79 -59
@@ -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
  })
@@ -121,11 +121,16 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
121
121
  const body = JSON.parse(await readBody(req)) as { alias?: string }
122
122
  const alias = body.alias ?? ''
123
123
  const clean = alias.trim()
124
- if (clean === '' || !/^[A-Za-z0-9_-]+$/.test(clean)) {
124
+ // 空 = 用供应商 id(默认值),合法;非空则校验字符集
125
+ if (clean !== '' && !/^[A-Za-z0-9_-]+$/.test(clean)) {
125
126
  writeJson(res, 400, { ok: false, error: '前缀只能包含字母、数字、- 和 _' })
126
127
  return
127
128
  }
128
- store.setAlias(s.id, clean)
129
+ const r = store.setAlias(s.id, clean)
130
+ if (!r.ok) {
131
+ writeJson(res, 400, { ok: false, error: `前缀已被供应商 ${JSON.stringify(r.conflictWith ?? '?')} 占用` })
132
+ return
133
+ }
129
134
  writeJson(res, 200, { ok: true })
130
135
  },
131
136
  })
@@ -137,13 +142,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
137
142
  handler: async (req, res) => {
138
143
  const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
139
144
  const cfg = store.get(s.id)
140
- const models = await cachedModels(s, cfg)
145
+ const models = await router.modelsOf(s.id)
141
146
  if (!body.id || !models.some((mm) => mm.id === body.id)) {
142
147
  writeJson(res, 400, { ok: false, error: '模型不存在' })
143
148
  return
144
149
  }
145
150
  store.setModelEnabled(s.id, body.id, !!body.enabled)
146
- modelsCache.delete(s.id) // 启用状态变化后失效缓存
151
+ router.invalidateModels(s.id)
147
152
  writeJson(res, 200, { ok: true })
148
153
  },
149
154
  })
@@ -159,13 +164,13 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
159
164
  writeJson(res, 400, { ok: false, error: '模型 id 不能为空' })
160
165
  return
161
166
  }
162
- const models = await cachedModels(s, store.get(s.id))
167
+ const models = await router.modelsOf(s.id)
163
168
  if (models.some((mm) => mm.id === id)) {
164
169
  writeJson(res, 400, { ok: false, error: `模型 ${id} 已存在` })
165
170
  return
166
171
  }
167
172
  store.addCustomModel(s.id, id)
168
- modelsCache.delete(s.id)
173
+ router.invalidateModels(s.id)
169
174
  writeJson(res, 200, { ok: true })
170
175
  },
171
176
  })
@@ -182,7 +187,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
182
187
  return
183
188
  }
184
189
  store.removeCustomModel(s.id, body.id)
185
- modelsCache.delete(s.id)
190
+ router.invalidateModels(s.id)
186
191
  writeJson(res, 200, { ok: true })
187
192
  },
188
193
  })
@@ -193,9 +198,9 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
193
198
  path: `${p}/models/bulk`,
194
199
  handler: async (req, res) => {
195
200
  const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
196
- const models = await cachedModels(s, store.get(s.id))
201
+ const models = await router.modelsOf(s.id)
197
202
  store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
198
- modelsCache.delete(s.id)
203
+ router.invalidateModels(s.id)
199
204
  writeJson(res, 200, { ok: true })
200
205
  },
201
206
  })
@@ -242,18 +247,18 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
242
247
  kind: 'exact',
243
248
  path: `${p}/models/fetch`,
244
249
  handler: async (_req, res) => {
245
- const models = await cachedModels(s, store.get(s.id), true)
250
+ const models = await router.modelsOf(s.id, true)
246
251
  writeJson(res, 200, { ok: true, models })
247
252
  },
248
253
  })
249
254
 
250
- // ---- 必要差异化: testModel(所有供应商必须实现) ----
255
+ // ---- 通用: 测试模型(核心统一走 chatOnce 路径,账号池回退/冷却自动生效) ----
251
256
  routes.push({
252
257
  kind: 'exact',
253
258
  path: `${p}/models/test`,
254
259
  handler: async (req, res) => {
255
260
  const body = JSON.parse(await readBody(req)) as { id?: string }
256
- const result = await s.testModel(body.id ?? '')
261
+ const result = await router.testModel(s.id, body.id ?? '')
257
262
  writeJson(res, result.ok ? 200 : 400, result)
258
263
  },
259
264
  })
@@ -327,22 +332,19 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
327
332
  },
328
333
  })
329
334
 
330
- // ---- 通用: 签到规则(dsh-router 策略,存 SupplierConfigStore) ----
335
+ // ---- 通用: 刷新链接池(积分 + 健康) ----
336
+ // 刷新是核心的活,但只调插件已有的 status()——插件在 status() 里自带积分
337
+ // 异步刷新(缓存过期才真拉上游),冷却/禁用(健康)也是 status() 实时算的。
331
338
  routes.push({
332
339
  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
340
+ path: `${p}/links/refresh`,
341
+ handler: async (_req, res) => {
342
+ try {
343
+ const changed = await settleStatus(s)
344
+ writeJson(res, 200, { ok: true, changed, accounts: s.status().accounts })
345
+ } catch (err) {
346
+ writeJson(res, 500, { ok: false, error: (err as Error).message })
343
347
  }
344
- store.setCheckinRule(s.id, body.rule)
345
- writeJson(res, 200, { ok: true })
346
348
  },
347
349
  })
348
350
 
@@ -351,12 +353,30 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
351
353
  kind: 'exact',
352
354
  path: `${p}/checkin`,
353
355
  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 })
356
+ // 签到 = 所有链接逐个签一次:checkinNow 是单账号能力,
357
+ // 遍历与汇总是核心的活(插件不负责连接池顺序/范围)。
358
+ // 禁用与否由插件自己判定(如 traework 返回 status:'disabled'),核心不替它筛。
359
+ const checkinOne = m.checkinNow as
360
+ ((uid: string) => Promise<{ ok: boolean; status: string; message?: string }>) | undefined
361
+ if (!checkinOne) {
362
+ writeJson(res, 400, { ok: false, error: '该供应商不支持签到' })
363
+ return
364
+ }
365
+ type CheckinResult = { uid: string; ok: boolean; status: string; message?: string }
366
+ const uids = s.status().accounts.map((a) => a.uid)
367
+ const results: CheckinResult[] = []
368
+ // 单个链接抛错不能带垮整批:记成 error 继续下一个
369
+ for (const uid of uids) {
370
+ try {
371
+ results.push({ uid, ...(await checkinOne.call(loaded.supplier, uid)) })
372
+ } catch (err) {
373
+ results.push({ uid, ok: false, status: 'error', message: (err as Error).message })
374
+ }
359
375
  }
376
+ const succeeded = results.filter((r) => r.status === 'ok').length
377
+ const already = results.filter((r) => r.status === 'already').length
378
+ const payload = { ok: succeeded + already > 0, total: uids.length, succeeded, already, results }
379
+ writeJson(res, payload.ok ? 200 : 400, payload)
360
380
  },
361
381
  })
362
382
  }