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.
- package/README.md +58 -13
- package/lib/client-registry.js +824 -153
- package/lib/client.js +824 -153
- package/lib/index.js +1606 -227
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +72 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +69 -0
- package/lib/types/supplier-config.d.ts +15 -4
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +28 -16
- package/src/client/RouterView.tsx +15 -2
- package/src/client/StatsTab.tsx +521 -0
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +406 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +52 -4
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +652 -26
- package/src/router/models-cache.test.ts +455 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/types.ts +12 -7
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +77 -0
- package/src/supplier-config.ts +31 -14
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +21 -13
- package/src/suppliers/nvidia/plugin.ts +161 -119
- package/src/suppliers/opencode/plugin.ts +47 -95
- package/src/suppliers/openrouter/plugin.ts +67 -122
- package/src/suppliers/registry.test.ts +194 -0
- 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
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
201
|
+
const models = await router.modelsOf(s.id)
|
|
197
202
|
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
|
|
198
|
-
|
|
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
|
|
250
|
+
const models = await router.modelsOf(s.id, true)
|
|
246
251
|
writeJson(res, 200, { ok: true, models })
|
|
247
252
|
},
|
|
248
253
|
})
|
|
249
254
|
|
|
250
|
-
// ----
|
|
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
|
|
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
|
-
// ---- 通用:
|
|
335
|
+
// ---- 通用: 刷新链接池(积分 + 健康) ----
|
|
336
|
+
// 刷新是核心的活,但只调插件已有的 status()——插件在 status() 里自带积分
|
|
337
|
+
// 异步刷新(缓存过期才真拉上游),冷却/禁用(健康)也是 status() 实时算的。
|
|
331
338
|
routes.push({
|
|
332
339
|
kind: 'exact',
|
|
333
|
-
path: `${p}/
|
|
334
|
-
handler: async (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
}
|