dsh-router-core 0.3.3 → 0.3.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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 积分回灌(loader 层)测试 —— 锁住 2026-09-03 那个 bug:
3
+ *
4
+ * codebuddy 插件只在内存里缓存积分,重启后 status() 报 0(当时还没有 -1 哨兵),
5
+ * 核心又没有持久化 → 面板每次重启都显示 0 积分,而 traework 因为自己落盘
6
+ * state.json 所以有值。现在由核心统一持久化:插件报 -1 就顶上次的缓存。
7
+ *
8
+ * 这里测的是 wrapModule 包装后的 status()(面板真正读的那个)。
9
+ */
10
+ import test from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { mkdtempSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { wrapModule } from './loader.ts'
16
+ import { SupplierConfigStore } from '../supplier-config.ts'
17
+ import type { CredentialStore } from '../credential-store.ts'
18
+ import type { SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
19
+ import type { ChatRequest } from '../router/types.ts'
20
+
21
+ const UNKNOWN = -1
22
+
23
+ /** 造一个 env(真实 SupplierConfigStore 落临时目录,CredentialStore 用不着的空壳)。 */
24
+ function env(): { env: Parameters<typeof wrapModule>[1]; store: SupplierConfigStore; file: string } {
25
+ const dir = mkdtempSync(join(tmpdir(), 'dshr-hydrate-'))
26
+ const stateFile = join(dir, 'state.json')
27
+ const store = new SupplierConfigStore(stateFile)
28
+ return {
29
+ env: { dataDir: dir, log: () => {}, store, credentials: {} as CredentialStore },
30
+ store,
31
+ file: join(dir, 'supplier-config.json'),
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 造一个插件:第 N 次 status() 报什么由脚本决定(模拟「重启后第一次拿不到,
37
+ * 异步拉完才有值」)。
38
+ */
39
+ function plugin(id: string, script: Array<SupplierAccountNow[]>): SupplierModule & { calls: () => number } {
40
+ let n = 0
41
+ return {
42
+ calls: () => n,
43
+ id,
44
+ name: id,
45
+ // 脚本放完就停在最后一帧(模拟「第一次还没拉到 → 异步拉完有值」)
46
+ status: () => ({ id, name: id, accounts: script[Math.min(n++, script.length - 1)]! }),
47
+ listModels: () => [],
48
+ getAlias: () => id,
49
+ chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
50
+ dispose: () => {},
51
+ } as unknown as SupplierModule & { calls: () => number }
52
+ }
53
+
54
+ const acct = (uid: string, credits: number): SupplierAccountNow => ({ uid, credits, state: 'ok' })
55
+
56
+ test('插件报 -1(刚重启还没拉到)→ 顶上次缓存,面板不显示 0', () => {
57
+ const { env: e, store } = env()
58
+ // 先跑一轮拿到真值(模拟上次运行)
59
+ const first = wrapModule(plugin('codebuddy', [[acct('cb-1', 1500)]]), e, 'test')
60
+ assert.equal(first.supplier.status().accounts[0]?.credits, 1500)
61
+
62
+ // 再换个实例(模拟重启):插件报 -1,核心应该顶出 1500
63
+ const restarted = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
64
+ assert.equal(restarted.supplier.status().accounts[0]?.credits, 1500)
65
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 1500)
66
+ })
67
+
68
+ test('插件报真值 → 缓存被刷新(不是一直吃旧值)', async () => {
69
+ const { env: e, store } = env()
70
+ store.putCredits('codebuddy', 'cb-1', 1500)
71
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 900)]]), e, 'test')
72
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 900)
73
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 900)
74
+ })
75
+
76
+ test('插件报 0(真用完了)→ 缓存被冲成 0,不被当成未知', async () => {
77
+ const { env: e, store } = env()
78
+ store.putCredits('codebuddy', 'cb-1', 500)
79
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 0)]]), e, 'test')
80
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 0)
81
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 0)
82
+ })
83
+
84
+ test('从没缓存过 + 插件报 -1 → 就是 -1(面板显示「积分未知」,不编 0)', async () => {
85
+ const { env: e } = env()
86
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
87
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, UNKNOWN)
88
+ })
89
+
90
+ test('异步落地:第一帧 -1(顶旧值)→ 第二帧真值(覆盖)', async () => {
91
+ const { env: e, store } = env()
92
+ store.putCredits('codebuddy', 'cb-1', 800)
93
+ // 模拟插件 fire-and-forget:第一次 status() 报 -1,异步拉完第二次报真值
94
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)], [acct('cb-1', 1234)]]), e, 'test')
95
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 800)
96
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 1234)
97
+ assert.equal(store.getCredits('codebuddy', 'cb-1'), 1234)
98
+ })
99
+
100
+ test('多账号各自独立回灌', async () => {
101
+ const { env: e, store } = env()
102
+ store.putCredits('codebuddy', 'cb-1', 100)
103
+ store.putCredits('codebuddy', 'cb-2', 200)
104
+ const loaded = wrapModule(
105
+ plugin('codebuddy', [[acct('cb-1', UNKNOWN), acct('cb-2', 250)]]),
106
+ e,
107
+ 'test',
108
+ )
109
+ const by = new Map(loaded.supplier.status().accounts.map((a) => [a.uid, a.credits]))
110
+ assert.equal(by.get('cb-1'), 100) // 顶缓存
111
+ assert.equal(by.get('cb-2'), 250) // 用真值
112
+ })
113
+
114
+ test('不持久化(store fp 为空)时也不炸', async () => {
115
+ const e = { dataDir: '', log: () => {}, store: new SupplierConfigStore(''), credentials: {} as CredentialStore }
116
+ const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 50)]]), e, 'test')
117
+ assert.equal(loaded.supplier.status().accounts[0]?.credits, 50)
118
+ })
@@ -36,6 +36,24 @@ function isFn(v: unknown): v is (...args: unknown[]) => unknown {
36
36
  return typeof v === 'function'
37
37
  }
38
38
 
39
+ /**
40
+ * 积分回灌(核心统一持久化,插件不落盘)。
41
+ *
42
+ * 插件的积分是异步拉的:重启后第一次 status() 拿不到真值,报 `CREDITS_UNKNOWN`
43
+ * (-1)。这里做两件事:
44
+ * - 报了真值 → 写入 supplier-config.json(下次重启还在)
45
+ * - 报了 -1 → 用上次存的顶上,面板不会闪 0
46
+ *
47
+ * 插件不该自己把 0 当「未知」报上来 —— 0 是真值(用完了),核心分不清,
48
+ * 会把缓存冲成 0。这条是本层存在的理由(2026-09-03)。
49
+ */
50
+ function hydrateCredits(supplierId: string, store: SupplierConfigStore, accounts: SupplierAccountNow[]): SupplierAccountNow[] {
51
+ return accounts.map((a) => {
52
+ const merged = store.putCredits(supplierId, a.uid, a.credits)
53
+ return merged === a.credits ? a : { ...a, credits: merged }
54
+ })
55
+ }
56
+
39
57
  /** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
40
58
  function resolveModule(mod: Record<string, unknown>, file: string): SupplierModule {
41
59
  const cand = (mod.default ?? mod) as Record<string, unknown>
@@ -108,7 +126,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
108
126
  icon: (instance as { icon?: string }).icon,
109
127
  status: (): SupplierStatus => {
110
128
  const now = instance.status()
111
- return { id: now.id, name: now.name, accounts: pool.decorate(now.accounts) }
129
+ return { id: now.id, name: now.name, accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts)) }
112
130
  },
113
131
  listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
114
132
  // 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
@@ -129,9 +147,6 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
129
147
  customModelIds: (): string[] => [...env.store.get(instance.id).custom],
130
148
  accounts: (): SupplierAccountNow[] => instance.status().accounts,
131
149
  chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
132
- lastError: isFn((instance as unknown as Record<string, unknown>).lastError)
133
- ? (): string | undefined => (instance as unknown as { lastError(): string | undefined }).lastError()
134
- : undefined,
135
150
  // 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
136
151
  removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
137
152
  ? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
@@ -54,7 +54,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
54
54
  // 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
55
55
  let modelsCache: ModelInfo[] | undefined
56
56
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
57
- let lastErr: string | undefined
58
57
  function listKeys(): string[] {
59
58
  return env.credentials.list(id)
60
59
  }
@@ -223,7 +222,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
223
222
  return { id, name, accounts }
224
223
  },
225
224
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
226
- getAlias: (): string => id,
227
225
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
228
226
  const key = input.apiKey.trim()
229
227
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -246,18 +244,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
246
244
  env.credentials.remove(id, uid)
247
245
  return true
248
246
  },
249
- lastError: (): string | undefined => lastErr,
250
247
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
251
248
  async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
252
249
  const base = stripAlias(req.model, currentAlias())
253
250
  if (!(await allModels(false)).some((m) => m.id === base)) {
254
- lastErr = `unknown model ${JSON.stringify(req.model)}`
255
- return { ok: false, state: 'no_such_model', message: lastErr }
251
+ const msg = `unknown model ${JSON.stringify(req.model)}`
252
+ return { ok: false, state: 'no_such_model', message: msg }
256
253
  }
257
254
  const acct = getKey(uid)
258
255
  if (acct === undefined) {
259
- lastErr = `unknown account ${JSON.stringify(uid)}`
260
- return { ok: false, state: 'no_such_model', message: lastErr }
256
+ const msg = `unknown account ${JSON.stringify(uid)}`
257
+ return { ok: false, state: 'no_such_model', message: msg }
261
258
  }
262
259
 
263
260
  let body = req.rawBody
@@ -278,33 +275,33 @@ export default function factory(env: SupplierEnv): SupplierModule {
278
275
  signal: AbortSignal.timeout(120000),
279
276
  })
280
277
  } catch (err) {
281
- lastErr = (err as Error).message
282
- return { ok: false, state: 'transport', message: lastErr }
278
+ const msg = (err as Error).message
279
+ return { ok: false, state: 'transport', message: msg }
283
280
  }
284
281
 
285
282
  if (upstream.status < 200 || upstream.status >= 300) {
286
283
  const text = await upstream.text().catch(() => '')
287
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
284
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
288
285
  const state: AccountState =
289
286
  upstream.status === 429 ? 'rate_limit'
290
287
  : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
291
288
  : upstream.status === 404 ? 'unavailable'
292
289
  : 'unknown'
293
- return { ok: false, state, message: lastErr }
290
+ return { ok: false, state, message: msg }
294
291
  }
295
292
 
296
293
  // 流式:上游已是 OpenAI SSE,原样交回核心写
297
294
  if (req.stream) {
298
295
  if (!upstream.body) {
299
- lastErr = 'nvidia upstream: empty stream body'
300
- return { ok: false, state: 'transport', message: lastErr }
296
+ const msg = 'nvidia upstream: empty stream body'
297
+ return { ok: false, state: 'transport', message: msg }
301
298
  }
302
299
  return { ok: true, stream: upstream.body }
303
300
  }
304
301
  const text = await upstream.text().catch(() => '')
305
302
  if (text === '') {
306
- lastErr = 'nvidia upstream: empty body'
307
- return { ok: false, state: 'transport', message: lastErr }
303
+ const msg = 'nvidia upstream: empty body'
304
+ return { ok: false, state: 'transport', message: msg }
308
305
  }
309
306
  return { ok: true, status: upstream.status, body: text }
310
307
  },
@@ -30,7 +30,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
30
30
  // 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
31
31
  let modelsCache: ModelInfo[] | undefined
32
32
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
33
- let lastErr: string | undefined
34
33
 
35
34
  async function refreshModels(): Promise<ModelInfo[]> {
36
35
  try {
@@ -81,15 +80,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
81
80
  icon,
82
81
  status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
83
82
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
84
- getAlias: (): string => id,
85
83
  // testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
86
- lastError: (): string | undefined => lastErr,
87
84
  /** 无账号供应商:uid 恒为空,只认 free 模型。 */
88
85
  async chatOnce(_uid: string, req: ChatRequest): Promise<ChatOnceResult> {
89
86
  // 只认 free 模型,其他模型交给别的供应商
90
87
  if (!isFreeModel(req.model)) {
91
- lastErr = `unknown free model ${JSON.stringify(req.model)}`
92
- return { ok: false, state: 'no_such_model', message: lastErr }
88
+ const msg = `unknown free model ${JSON.stringify(req.model)}`
89
+ return { ok: false, state: 'no_such_model', message: msg }
93
90
  }
94
91
  // 模型名规范化:剥 alias 前缀,body.model 用裸 id
95
92
  let body = req.rawBody
@@ -115,24 +112,24 @@ export default function factory(env: SupplierEnv): SupplierModule {
115
112
  signal: AbortSignal.timeout(120000),
116
113
  })
117
114
  } catch (err) {
118
- lastErr = `opencode upstream: ${(err as Error).message}`
119
- return { ok: false, state: 'transport', message: lastErr }
115
+ const msg = `opencode upstream: ${(err as Error).message}`
116
+ return { ok: false, state: 'transport', message: msg }
120
117
  }
121
118
 
122
119
  // 非 2xx:按状态归类,核心据此冷却/换号(本供应商无号可换,直接放弃)
123
120
  if (upstream.status < 200 || upstream.status >= 300) {
124
121
  const text = await upstream.text().catch(() => '')
125
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 200)}`
122
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
126
123
  const state: AccountState =
127
124
  upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
128
- return { ok: false, state, message: lastErr }
125
+ return { ok: false, state, message: msg }
129
126
  }
130
127
 
131
128
  // 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
132
129
  if (req.stream) {
133
130
  if (!upstream.body) {
134
- lastErr = 'opencode upstream: empty stream body'
135
- return { ok: false, state: 'transport', message: lastErr }
131
+ const msg = 'opencode upstream: empty stream body'
132
+ return { ok: false, state: 'transport', message: msg }
136
133
  }
137
134
  return { ok: true, stream: upstream.body }
138
135
  }
@@ -140,8 +137,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
140
137
  // 非流式:透传 JSON(空 body 视为上游异常)
141
138
  const text = await upstream.text().catch(() => '')
142
139
  if (text === '') {
143
- lastErr = 'opencode upstream: empty body'
144
- return { ok: false, state: 'transport', message: lastErr }
140
+ const msg = 'opencode upstream: empty body'
141
+ return { ok: false, state: 'transport', message: msg }
145
142
  }
146
143
  return { ok: true, status: upstream.status, body: text }
147
144
  },
@@ -50,7 +50,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
50
50
  let modelsCache: ModelInfo[] | undefined
51
51
  let freeIds = new Set<string>()
52
52
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
53
- let lastErr: string | undefined
54
53
  function listKeys(): string[] {
55
54
  return env.credentials.list(id)
56
55
  }
@@ -121,7 +120,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
121
120
  return { id, name, accounts }
122
121
  },
123
122
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
124
- getAlias: (): string => id,
125
123
  async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
126
124
  const key = input.apiKey.trim()
127
125
  if (key === '') return { ok: false, error: 'API key 不能为空' }
@@ -151,18 +149,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
151
149
  env.credentials.remove(id, uid)
152
150
  return true
153
151
  },
154
- lastError: (): string | undefined => lastErr,
155
152
  /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
156
153
  async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
157
154
  const base = stripAlias(req.model, currentAlias())
158
155
  if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
159
- lastErr = `unknown free model ${JSON.stringify(req.model)}`
160
- return { ok: false, state: 'no_such_model', message: lastErr }
156
+ const msg = `unknown free model ${JSON.stringify(req.model)}`
157
+ return { ok: false, state: 'no_such_model', message: msg }
161
158
  }
162
159
  const acct = getKey(uid)
163
160
  if (acct === undefined) {
164
- lastErr = `unknown account ${JSON.stringify(uid)}`
165
- return { ok: false, state: 'no_such_model', message: lastErr }
161
+ const msg = `unknown account ${JSON.stringify(uid)}`
162
+ return { ok: false, state: 'no_such_model', message: msg }
166
163
  }
167
164
 
168
165
  let body = req.rawBody
@@ -188,34 +185,34 @@ export default function factory(env: SupplierEnv): SupplierModule {
188
185
  signal: AbortSignal.timeout(120000),
189
186
  })
190
187
  } catch (err) {
191
- lastErr = (err as Error).message
192
- return { ok: false, state: 'transport', message: lastErr }
188
+ const msg = (err as Error).message
189
+ return { ok: false, state: 'transport', message: msg }
193
190
  }
194
191
 
195
192
  if (upstream.status < 200 || upstream.status >= 300) {
196
193
  const text = await upstream.text().catch(() => '')
197
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
194
+ const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
198
195
  // 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
199
196
  const state: AccountState =
200
197
  upstream.status === 429 ? 'rate_limit'
201
198
  : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
202
199
  : upstream.status === 404 ? 'unavailable'
203
200
  : 'unknown'
204
- return { ok: false, state, message: lastErr }
201
+ return { ok: false, state, message: msg }
205
202
  }
206
203
 
207
204
  // 流式:上游已是 OpenAI SSE,原样交回核心写
208
205
  if (req.stream) {
209
206
  if (!upstream.body) {
210
- lastErr = 'openrouter upstream: empty stream body'
211
- return { ok: false, state: 'transport', message: lastErr }
207
+ const msg = 'openrouter upstream: empty stream body'
208
+ return { ok: false, state: 'transport', message: msg }
212
209
  }
213
210
  return { ok: true, stream: upstream.body }
214
211
  }
215
212
  const text = await upstream.text().catch(() => '')
216
213
  if (text === '') {
217
- lastErr = 'openrouter upstream: empty body'
218
- return { ok: false, state: 'transport', message: lastErr }
214
+ const msg = 'openrouter upstream: empty body'
215
+ return { ok: false, state: 'transport', message: msg }
219
216
  }
220
217
  return { ok: true, status: upstream.status, body: text }
221
218
  },
@@ -327,7 +327,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
327
327
  return
328
328
  }
329
329
  const body = JSON.parse(await readBody(req)) as { uid?: string }
330
- const ok = await s.removeLink(body.uid ?? '')
330
+ const uid = body.uid ?? ''
331
+ const ok = await s.removeLink(uid)
332
+ // 链接没了,它的积分缓存也得跟着走(不然删号再重登会顶着一个旧数字)
333
+ if (ok) store.clearCredits(s.id, uid)
331
334
  writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'link not found' })
332
335
  },
333
336
  })