dsh-router-core 0.3.3 → 0.3.5
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 +1 -1
- package/cordis.patch.yml +7 -1
- package/docs/suppliers.md +71 -30
- package/lib/client-registry.js +7 -3
- package/lib/client.js +8 -4
- package/lib/index.js +145 -51
- package/lib/suppliers/nvidia.js +339 -0
- package/lib/suppliers/opencode.js +137 -0
- package/lib/suppliers/openrouter.js +222 -0
- package/lib/types/index.d.ts +5 -2
- package/lib/types/router/index.d.ts +23 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +7 -4
- package/src/client/SupplierDetail.tsx +7 -2
- package/src/index.ts +5 -2
- package/src/router/index.ts +88 -28
- package/src/router/models-cache.test.ts +137 -2
- package/src/router/types.ts +0 -2
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +19 -4
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.ts +4 -1
|
@@ -66,6 +66,21 @@ function addSupplier(router: Router, s: unknown): void {
|
|
|
66
66
|
;(router as unknown as { suppliers: unknown[] }).suppliers.push(s)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 把某供应商的缓存标记成「已过期」(TTL 现在 10 分钟,测试不能真等)。
|
|
71
|
+
* 白盒改 fetchedAt,模拟插件重启/TTL 到期的冷路径。
|
|
72
|
+
*/
|
|
73
|
+
function expire(router: Router, id: string): void {
|
|
74
|
+
const cache = (router as unknown as { modelsCache: Map<string, { fetchedAt: number }> }).modelsCache
|
|
75
|
+
const hit = cache.get(id)
|
|
76
|
+
if (hit !== undefined) hit.fetchedAt = 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 等一轮 microtask/定时器,让 fire-and-forget 的后台刷新跑完。 */
|
|
80
|
+
function tick(ms = 20): Promise<void> {
|
|
81
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
test('组合面板连开两次只拉一次上游(缓存生效)', async () => {
|
|
70
85
|
const router = new Router('')
|
|
71
86
|
const a = supplier('a', [{ id: 'a-1' }])
|
|
@@ -137,9 +152,129 @@ test('单个供应商拉模型失败不影响其它供应商', async () => {
|
|
|
137
152
|
assert.equal(groups[0]?.supplier.id, 'good')
|
|
138
153
|
})
|
|
139
154
|
|
|
140
|
-
|
|
155
|
+
/**
|
|
156
|
+
* 冷启动慢的回归(2026-09-03 实测):插件的 listModels 大多无条件打上游
|
|
157
|
+
* (0.3~1.0s/供应商),TTL 一过就穿透到插件,组合页取最慢那家 = 1s+ 白屏。
|
|
158
|
+
* 现在核心走 stale-while-revalidate:过期先返回旧值,后台刷新。
|
|
159
|
+
*/
|
|
160
|
+
test('过期后不等上游:立刻返回旧值,后台刷新', async () => {
|
|
161
|
+
const router = new Router('')
|
|
162
|
+
// 慢供应商:真等它就是 300ms
|
|
163
|
+
const a = supplier('a', [{ id: 'a-1' }], 300)
|
|
164
|
+
addSupplier(router, a.s)
|
|
165
|
+
|
|
166
|
+
await router.modelsOf('a')
|
|
167
|
+
assert.equal(a.state.calls, 1)
|
|
168
|
+
expire(router, 'a')
|
|
169
|
+
|
|
170
|
+
const t0 = Date.now()
|
|
171
|
+
const models = await router.modelsOf('a')
|
|
172
|
+
const dt = Date.now() - t0
|
|
173
|
+
|
|
174
|
+
assert.ok(dt < 100, `过期后不该同步等上游(实测 ${dt}ms)`)
|
|
175
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '旧值要立刻返回,面板不空')
|
|
176
|
+
await tick(400)
|
|
177
|
+
assert.equal(a.state.calls, 2, '后台要真的刷新一次')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('后台刷新后缓存变新:再取不再触发拉取', async () => {
|
|
141
181
|
const router = new Router('')
|
|
142
|
-
|
|
182
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
183
|
+
addSupplier(router, a.s)
|
|
184
|
+
|
|
185
|
+
await router.modelsOf('a')
|
|
186
|
+
expire(router, 'a')
|
|
187
|
+
await router.modelsOf('a') // 触发后台刷新
|
|
188
|
+
await tick()
|
|
189
|
+
assert.equal(a.state.calls, 2)
|
|
190
|
+
|
|
191
|
+
await router.modelsOf('a') // 已刷新过,不该再拉
|
|
192
|
+
await tick()
|
|
193
|
+
assert.equal(a.state.calls, 2)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('并发刷新共享一次上游请求(不去重会把上游按 N 倍打)', async () => {
|
|
197
|
+
const router = new Router('')
|
|
198
|
+
const a = supplier('a', [{ id: 'a-1' }], 50)
|
|
199
|
+
addSupplier(router, a.s)
|
|
200
|
+
await router.modelsOf('a')
|
|
201
|
+
expire(router, 'a')
|
|
202
|
+
|
|
203
|
+
// 组合页的实际情况:多个入口同时触发同一个供应商
|
|
204
|
+
await Promise.all([router.modelsOf('a'), router.modelsOf('a'), router.modelsOf('a')])
|
|
205
|
+
await tick(200)
|
|
206
|
+
assert.equal(a.state.calls, 2, `并发只应多拉一次,实测 ${a.state.calls}`)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('force 仍然同步等(用户主动点「获取模型」,要看到结果)', async () => {
|
|
210
|
+
const router = new Router('')
|
|
211
|
+
const a = supplier('a', [{ id: 'a-1' }], 150)
|
|
212
|
+
addSupplier(router, a.s)
|
|
213
|
+
await router.modelsOf('a')
|
|
214
|
+
expire(router, 'a')
|
|
215
|
+
|
|
216
|
+
const t0 = Date.now()
|
|
217
|
+
await router.modelsOf('a', true)
|
|
218
|
+
assert.ok(Date.now() - t0 >= 100, 'force 必须等上游拉完')
|
|
219
|
+
assert.equal(a.state.calls, 2)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('拉到空列表不覆盖旧值(上游抖一下不该清空面板)', async () => {
|
|
223
|
+
const router = new Router('')
|
|
224
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
225
|
+
addSupplier(router, a.s)
|
|
226
|
+
await router.modelsOf('a')
|
|
227
|
+
|
|
228
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => []
|
|
229
|
+
expire(router, 'a')
|
|
230
|
+
const models = await router.modelsOf('a', true)
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '空结果不该冲掉旧列表')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('后台刷新失败 → 保留旧值,不抛(fire-and-forget 没人接异常)', async () => {
|
|
236
|
+
const router = new Router('')
|
|
237
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
238
|
+
addSupplier(router, a.s)
|
|
239
|
+
await router.modelsOf('a')
|
|
240
|
+
|
|
241
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => {
|
|
242
|
+
throw new Error('upstream down')
|
|
243
|
+
}
|
|
244
|
+
expire(router, 'a')
|
|
245
|
+
const models = await router.modelsOf('a')
|
|
246
|
+
assert.deepEqual(models.map((m) => m.id), ['a-1'], '失败也要保住旧值')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('首次就失败且无旧值 → 抛错(让 supplierModels 剔除该供应商,别显示空壳)', async () => {
|
|
250
|
+
const router = new Router('')
|
|
251
|
+
const a = supplier('a', [])
|
|
252
|
+
a.s.listModels = async (): Promise<ModelInfo[]> => {
|
|
253
|
+
throw new Error('upstream down')
|
|
254
|
+
}
|
|
255
|
+
addSupplier(router, a.s)
|
|
256
|
+
|
|
257
|
+
await assert.rejects(() => router.modelsOf('a'), /upstream down/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 回归(2026-09-03 契约精简):删了插件 lastError() 后,testModel 的失败原因
|
|
262
|
+
* 必须从 chatOnce 返回值的 message 取(经 trace.lastError 透传)。以前问插件
|
|
263
|
+
* 的 lastError() 是个「最后一次失败」的可变全局,可能串别的请求,也有插件
|
|
264
|
+
* 压根没实现照样能走到。锁住:chatOnce 报的 message 能作为诊断呈现。
|
|
265
|
+
*/
|
|
266
|
+
test('testModel 失败 → 用 chatOnce 返回的 message 作诊断,不依赖插件 lastError()', async () => {
|
|
267
|
+
const router = new Router('')
|
|
268
|
+
const a = supplier('a', [{ id: 'a-1' }])
|
|
269
|
+
// 明确不实现 lastError()——合约删了它
|
|
270
|
+
a.s.chatOnce = async (): Promise<ChatOnceResult> =>
|
|
271
|
+
({ ok: false, state: 'quota', message: 'no quota for this plan' })
|
|
272
|
+
addSupplier(router, a.s)
|
|
273
|
+
|
|
274
|
+
const r = await router.testModel('a', 'a-1')
|
|
275
|
+
assert.equal(r.ok, false)
|
|
276
|
+
assert.match(r.error ?? '', /no quota for this plan/, '失败原因要来自 chatOnce 的 message')
|
|
277
|
+
assert.match(r.error ?? '', /账号\/额度\/限流问题/, '要带上可诊断的归类提示')
|
|
143
278
|
})
|
|
144
279
|
|
|
145
280
|
/**
|
package/src/router/types.ts
CHANGED
|
@@ -80,8 +80,6 @@ export interface Supplier {
|
|
|
80
80
|
accounts(): SupplierAccountNow[]
|
|
81
81
|
/** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
|
|
82
82
|
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
|
|
83
|
-
/** 上次 chatOnce 失败原因(诊断用,可选)。 */
|
|
84
|
-
lastError?(): string | undefined
|
|
85
83
|
/** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
|
|
86
84
|
removeLink?(uid: string): Promise<boolean>
|
|
87
85
|
dispose(): void
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 积分缓存(核心统一持久化)测试 —— node --test 直接跑。
|
|
3
|
+
*
|
|
4
|
+
* 锁定的契约(2026-09-03 修的 bug:codebuddy 重启后面板永久显示 0 积分):
|
|
5
|
+
* - 插件报真值 → 落盘,重启后还在
|
|
6
|
+
* - 插件报 -1(没拉到)→ 顶上次的缓存,不能显示 0
|
|
7
|
+
* - 插件报 0(真用完了)→ 缓存被冲成 0,不能被当成「未知」保住旧值
|
|
8
|
+
* - 删链接 → 缓存一起清
|
|
9
|
+
* - 坏数据(负数/NaN/缺字段)读盘时不炸,当没缓存过
|
|
10
|
+
*/
|
|
11
|
+
import test from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { SupplierConfigStore } from './supplier-config.ts'
|
|
17
|
+
|
|
18
|
+
/** 每个用例一个独立数据目录(stateFile 推导出 supplier-config.json 的位置)。 */
|
|
19
|
+
function tempStore(preload?: unknown): { store: SupplierConfigStore; file: string } {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-credits-'))
|
|
21
|
+
const stateFile = join(dir, 'state.json')
|
|
22
|
+
if (preload !== undefined) {
|
|
23
|
+
writeFileSync(join(dir, 'supplier-config.json'), JSON.stringify(preload))
|
|
24
|
+
}
|
|
25
|
+
return { store: new SupplierConfigStore(stateFile), file: join(dir, 'supplier-config.json') }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const UNKNOWN = -1
|
|
29
|
+
|
|
30
|
+
test('报真值 → 落盘,重开还在', () => {
|
|
31
|
+
const { store, file } = tempStore()
|
|
32
|
+
assert.equal(store.putCredits('codebuddy', 'cb-1', 1234.5), 1234.5)
|
|
33
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
34
|
+
suppliers: Record<string, { credits: Record<string, number> }>
|
|
35
|
+
}
|
|
36
|
+
assert.equal(raw.suppliers.codebuddy?.credits['cb-1'], 1234.5)
|
|
37
|
+
// 新实例 = 模拟重启
|
|
38
|
+
assert.equal(new SupplierConfigStore(join(file, '..', 'state.json')).getCredits('codebuddy', 'cb-1'), 1234.5)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('报 -1(没拉到)→ 顶上次缓存,不是 0', () => {
|
|
42
|
+
const { store } = tempStore()
|
|
43
|
+
store.putCredits('codebuddy', 'cb-1', 900)
|
|
44
|
+
// 插件重启后内存缓存空 → 报 -1
|
|
45
|
+
assert.equal(store.putCredits('codebuddy', 'cb-1', UNKNOWN), 900)
|
|
46
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 900)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('从来没拿到过 → 就是 -1(读不到就别编 0)', () => {
|
|
50
|
+
const { store } = tempStore()
|
|
51
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), UNKNOWN)
|
|
52
|
+
assert.equal(store.putCredits('codebuddy', 'cb-1', UNKNOWN), UNKNOWN)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('报 0(真用完了)→ 缓存被冲成 0', () => {
|
|
56
|
+
const { store } = tempStore()
|
|
57
|
+
store.putCredits('codebuddy', 'cb-1', 500)
|
|
58
|
+
assert.equal(store.putCredits('codebuddy', 'cb-1', 0), 0)
|
|
59
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('删链接 → 积分缓存一起清', () => {
|
|
63
|
+
const { store } = tempStore()
|
|
64
|
+
store.putCredits('codebuddy', 'cb-1', 700)
|
|
65
|
+
store.clearCredits('codebuddy', 'cb-1')
|
|
66
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), UNKNOWN)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('同值重复写入不落盘(status() 是高频调用)', () => {
|
|
70
|
+
const { store, file } = tempStore()
|
|
71
|
+
store.putCredits('codebuddy', 'cb-1', 42)
|
|
72
|
+
const before = readFileSync(file, 'utf8')
|
|
73
|
+
store.putCredits('codebuddy', 'cb-1', 42)
|
|
74
|
+
assert.equal(readFileSync(file, 'utf8'), before)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('坏数据读盘不炸:负数/NaN/非数字当没缓存过', () => {
|
|
78
|
+
const { store } = tempStore({
|
|
79
|
+
suppliers: { codebuddy: { credits: { 'cb-bad': -5, 'cb-nan': null, 'cb-ok': 300 } } },
|
|
80
|
+
})
|
|
81
|
+
assert.equal(store.getCredits('codebuddy', 'cb-bad'), UNKNOWN)
|
|
82
|
+
assert.equal(store.getCredits('codebuddy', 'cb-nan'), UNKNOWN)
|
|
83
|
+
assert.equal(store.getCredits('codebuddy', 'cb-ok'), 300)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('旧配置文件(无 credits 字段)能读,不炸', () => {
|
|
87
|
+
const { store } = tempStore({ suppliers: { codebuddy: { alias: 'cb', poolOrder: ['cb-1'] } } })
|
|
88
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), UNKNOWN)
|
|
89
|
+
assert.deepEqual(store.get('codebuddy').poolOrder, ['cb-1'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('按供应商隔离:同 uid 不同供应商互不干扰', () => {
|
|
93
|
+
const { store } = tempStore()
|
|
94
|
+
store.putCredits('codebuddy', 'key-1', 100)
|
|
95
|
+
store.putCredits('traework', 'key-1', 200)
|
|
96
|
+
assert.equal(store.getCredits('codebuddy', 'key-1'), 100)
|
|
97
|
+
assert.equal(store.getCredits('traework', 'key-1'), 200)
|
|
98
|
+
})
|
package/src/supplier-config.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* - 别名 alias(前缀,模型全名 = alias/id)
|
|
6
6
|
* - 模型管理:启用/禁用 disabled、自定义 custom
|
|
7
7
|
* - 连接池:poolOrder(拖动排序)、poolStrategy(回退/轮询)
|
|
8
|
+
* - 积分缓存 credits:按 supplier/uid 缓存最后一次拿到的剩余额度
|
|
8
9
|
*
|
|
9
10
|
* 持久化到 data/supplier-config.json。
|
|
10
11
|
*/
|
|
@@ -13,18 +14,40 @@ import { dirname, join } from 'node:path'
|
|
|
13
14
|
|
|
14
15
|
export type PoolStrategy = 'fallback' | 'round-robin'
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 积分哨兵:插件还**没拿到过**真实额度(刚重启、积分拉取失败、或压根不支持
|
|
19
|
+
* 积分)时报 `-1`,核心据此保留上一次的缓存值,而不是把 0 写进去。
|
|
20
|
+
*
|
|
21
|
+
* 为什么不能用 0 表示未知:积分 0 是有意义的真值(用完了),核心无法区分
|
|
22
|
+
* 「拿到 0」和「没拿到」。用 0 当哨兵会把缓存冲成 0 —— 这正是 codebuddy
|
|
23
|
+
* 重启后面板永久显示 0 积分的原因(插件只在内存里缓存积分)。
|
|
24
|
+
*/
|
|
25
|
+
export const CREDITS_UNKNOWN = -1
|
|
26
|
+
|
|
16
27
|
export interface SupplierConfig {
|
|
17
28
|
alias: string
|
|
18
29
|
disabled: string[]
|
|
19
30
|
custom: string[]
|
|
20
31
|
poolOrder: string[]
|
|
21
32
|
poolStrategy: PoolStrategy
|
|
33
|
+
/** 积分缓存:uid → 剩余额度(最后一次从插件拿到的非 -1 值)。 */
|
|
34
|
+
credits: Record<string, number>
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
interface ConfigFile {
|
|
25
38
|
suppliers: Record<string, Partial<SupplierConfig>>
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
/** 读盘时的积分字段校验:只留有限非负数(负数一律当没缓存过)。 */
|
|
42
|
+
function readCredits(raw: unknown): Record<string, number> {
|
|
43
|
+
const out: Record<string, number> = {}
|
|
44
|
+
if (typeof raw !== 'object' || raw === null) return out
|
|
45
|
+
for (const [uid, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
46
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[uid] = v
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
/** 通用供应商配置存储。 */
|
|
29
52
|
export class SupplierConfigStore {
|
|
30
53
|
private fp = ''
|
|
@@ -55,7 +78,7 @@ export class SupplierConfigStore {
|
|
|
55
78
|
get(supplierId: string): SupplierConfig {
|
|
56
79
|
let cfg = this.bySupplier.get(supplierId)
|
|
57
80
|
if (!cfg) {
|
|
58
|
-
cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback' }
|
|
81
|
+
cfg = { alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback', credits: {} }
|
|
59
82
|
this.bySupplier.set(supplierId, cfg)
|
|
60
83
|
}
|
|
61
84
|
return cfg
|
|
@@ -116,6 +139,40 @@ export class SupplierConfigStore {
|
|
|
116
139
|
this.saveLocked()
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
// ---- 积分缓存(核心统一持久化,插件只管报) ----
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 读缓存的积分。没缓存过返回 `CREDITS_UNKNOWN`(-1),让调用方自己决定
|
|
146
|
+
* 显示什么——不要把 -1 当 0 展示。
|
|
147
|
+
*/
|
|
148
|
+
getCredits(supplierId: string, uid: string): number {
|
|
149
|
+
const v = this.get(supplierId).credits[uid]
|
|
150
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : CREDITS_UNKNOWN
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 合并插件报的积分:非 -1 就写入并返回它;-1(未知)则回落到上次缓存值。
|
|
155
|
+
* @returns 面板该显示的积分(仍可能是 -1 = 从来没拿到过)
|
|
156
|
+
*/
|
|
157
|
+
putCredits(supplierId: string, uid: string, reported: number): number {
|
|
158
|
+
if (typeof reported !== 'number' || !Number.isFinite(reported) || reported < 0) {
|
|
159
|
+
return this.getCredits(supplierId, uid)
|
|
160
|
+
}
|
|
161
|
+
const prev = this.getCredits(supplierId, uid)
|
|
162
|
+
if (prev === reported) return reported // 值没变,不写盘(status() 是高频调用)
|
|
163
|
+
this.get(supplierId).credits[uid] = reported
|
|
164
|
+
this.saveLocked()
|
|
165
|
+
return reported
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 删除链接时清掉它的积分缓存(不写盘:下一行 removeLink 会带出保存)。 */
|
|
169
|
+
clearCredits(supplierId: string, uid: string): void {
|
|
170
|
+
const credits = this.get(supplierId).credits
|
|
171
|
+
if (credits[uid] === undefined) return
|
|
172
|
+
delete credits[uid]
|
|
173
|
+
this.saveLocked()
|
|
174
|
+
}
|
|
175
|
+
|
|
119
176
|
private load(): void {
|
|
120
177
|
if (this.fp === '') return
|
|
121
178
|
let f: ConfigFile
|
|
@@ -131,6 +188,7 @@ export class SupplierConfigStore {
|
|
|
131
188
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === 'string') : [],
|
|
132
189
|
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === 'string') : [],
|
|
133
190
|
poolStrategy: c.poolStrategy === 'round-robin' ? 'round-robin' : 'fallback',
|
|
191
|
+
credits: readCredits(c.credits),
|
|
134
192
|
})
|
|
135
193
|
}
|
|
136
194
|
}
|
|
@@ -145,6 +203,7 @@ export class SupplierConfigStore {
|
|
|
145
203
|
custom: [...cfg.custom],
|
|
146
204
|
poolOrder: [...cfg.poolOrder],
|
|
147
205
|
poolStrategy: cfg.poolStrategy,
|
|
206
|
+
credits: { ...cfg.credits },
|
|
148
207
|
}
|
|
149
208
|
}
|
|
150
209
|
try {
|
|
@@ -57,6 +57,12 @@ export type AccountState =
|
|
|
57
57
|
export interface SupplierAccountNow {
|
|
58
58
|
uid: string
|
|
59
59
|
nickname?: string
|
|
60
|
+
/**
|
|
61
|
+
* 剩余额度。**拿不到就报 `-1`**,不是 0:
|
|
62
|
+
* 插件刚重启(内存缓存空)、积分拉取失败、或压根不支持积分时都是 `-1`。
|
|
63
|
+
* 核心据此保留上一次持久化下来的值 —— 报 0 会把缓存冲成 0(0 是真值
|
|
64
|
+
* 「用完了」,核心分不清「拿到 0」和「没拿到」)。
|
|
65
|
+
*/
|
|
60
66
|
credits: number
|
|
61
67
|
/** 插件观察到的上游状态。 */
|
|
62
68
|
state: AccountState
|
|
@@ -96,25 +102,19 @@ export interface SupplierModule {
|
|
|
96
102
|
status(): SupplierStatusNow
|
|
97
103
|
/** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
|
98
104
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
|
|
99
|
-
/** 默认前缀(可被通用层 alias 覆盖)。 */
|
|
100
|
-
getAlias(): string
|
|
101
105
|
/**
|
|
102
106
|
* 对**单个账号**调一次上游。插件不遍历账号、不管冷却、不写 res——
|
|
103
107
|
* 选号/回退/健康判定全是核心的活。
|
|
104
108
|
* 无账号供应商(如 opencode)传空 uid,忽略即可。
|
|
109
|
+
*
|
|
110
|
+
* 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
|
|
111
|
+
* 插件不必自己存一份「上次失败」的状态。
|
|
105
112
|
*/
|
|
106
113
|
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>
|
|
107
114
|
dispose(): void
|
|
108
115
|
|
|
109
116
|
// ---- 差异化能力(可选,按存在性暴露端点/UI) ----
|
|
110
117
|
|
|
111
|
-
/** 上次 chatOnce 失败的原因(诊断用)。
|
|
112
|
-
* 测试模型由 dsh-router 核心统一走 chatOnce 路径(账号池回退/冷却自动生效),
|
|
113
|
-
* 插件只需把失败原因暴露出来供核心汇总;不实现则测试失败时只有通用提示。 */
|
|
114
|
-
lastError?(): string | undefined
|
|
115
|
-
|
|
116
|
-
// ---- 差异化能力(可选,按存在性暴露端点/UI) ----
|
|
117
|
-
|
|
118
118
|
/** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
|
|
119
119
|
generateLoginUrl?(): string | { ok: boolean; error?: string; loginUrl?: string } | Promise<string | { ok: boolean; error?: string; loginUrl?: string }>
|
|
120
120
|
/** 回调完成登录。 */
|
|
@@ -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
|
+
})
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
return { ok: false, state: 'no_such_model', message:
|
|
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
|
-
|
|
260
|
-
return { ok: false, state: 'no_such_model', message:
|
|
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
|
-
|
|
282
|
-
return { ok: false, state: 'transport', message:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
300
|
-
return { ok: false, state: 'transport', message:
|
|
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
|
-
|
|
307
|
-
return { ok: false, state: 'transport', message:
|
|
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
|
},
|