dsh-account-pool 0.1.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/lib/usage.js ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * 用量统计:逐请求记录 token 消耗,按 (时间片, 账号, 模型) 分桶累计。
3
+ *
4
+ * 统计口径参考 workbuddy2api 网关的 internal/usage:
5
+ * - 按小时分桶;桶数超过上限时,把「超过保留期」的小时桶折叠为日桶
6
+ * (注意:折叠由桶数上限触发,不是按时间定时触发——与参照实现一致)
7
+ * - 记录请求数、失败数、prompt/completion/total tokens、延迟、吐字速率
8
+ * - **失败也计入请求数**——否则「重试放大」在统计里看不见
9
+ *
10
+ * 与账号池里那个「每账号一个累计计数器」的区别:那边只有总量与最近一次,
11
+ * 没有时间维度,无法回答「今天各模型用了多少」这类问题。
12
+ *
13
+ * 落盘到 $DSH_HOME/.account-pool.usage.json,防抖写入,重启不丢。
14
+ */
15
+
16
+ import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ /**
20
+ * 小时桶的「保留期」判据:早于这个时长的小时桶在折叠时会被并成日桶。
21
+ *
22
+ * 注意它不是定时器——折叠只在桶数超过 MAX_BUCKETS 时触发,
23
+ * 这个常量决定「哪些桶算旧、可以并」。它也用来估算容量上界:
24
+ * 桶数 ≈ 账号数 × 模型数 × (保留期小时数 + 已过天数)。
25
+ */
26
+ const HOURLY_KEEP_MS = 90 * 24 * 3600 * 1000
27
+
28
+ /** 落盘防抖间隔。 */
29
+ const FLUSH_INTERVAL_MS = 30_000
30
+
31
+ /** 桶数硬上限:超过就立刻折叠一次,防止异常流量把内存撑爆。 */
32
+ const MAX_BUCKETS = 200_000
33
+
34
+ /** 落盘格式版本。 */
35
+ const FILE_VERSION = 1
36
+
37
+ /** 临时文件名序号:保证并发写入时各自的临时文件互不冲突。 */
38
+ let tmpSeq = 0
39
+
40
+ /** 本地时区的小时键。 */
41
+ function hourKey(date) {
42
+ const pad = (n) => String(n).padStart(2, '0')
43
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}`
44
+ }
45
+
46
+ /** 本地时区的日键。 */
47
+ function dayKey(date) {
48
+ const pad = (n) => String(n).padStart(2, '0')
49
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
50
+ }
51
+
52
+ /** 一个桶:某个 (时间片, 账号, 模型) 的累计量。 */
53
+ function newBucket(scope, accountId, model) {
54
+ return {
55
+ scope, accountId, model,
56
+ requests: 0, errors: 0,
57
+ promptTokens: 0, completionTokens: 0, totalTokens: 0,
58
+ latencySumMs: 0, latencySamples: 0,
59
+ tpsSum: 0, tpsSamples: 0,
60
+ credits: 0,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 用量记录器。
66
+ *
67
+ * 用法:
68
+ * const usage = new UsageRecorder({ file })
69
+ * usage.add({ accountId, model, ok, usage: {...}, latencyMs })
70
+ * usage.snapshot(72) // 取聚合视图
71
+ */
72
+ export class UsageRecorder {
73
+ /**
74
+ * @param {object} options
75
+ * @param {string} options.file 落盘路径
76
+ * @param {() => number} [options.now] 取当前时间(测试注入)
77
+ */
78
+ constructor({ file, now = () => Date.now(), legacyFiles = [] }) {
79
+ this.file = file
80
+ /**
81
+ * 历史文件名的只读迁移来源。
82
+ *
83
+ * 插件改名(workbuddy-pool → account-pool)后用量文件名也变了;
84
+ * 不做回落的话历史用量会"归零"。只读:一旦刷新就写到新文件。
85
+ */
86
+ this.legacyFiles = legacyFiles
87
+ this.now = now
88
+ /** key: `${scope}|${accountId}|${model}` → bucket */
89
+ this.buckets = new Map()
90
+ this.dirty = false
91
+ this.timer = null
92
+ this.loaded = false
93
+ /**
94
+ * 落盘文件的字节数。snapshot() 是同步的,没法现 stat(那是异步 API),
95
+ * 所以在 load/flush 时顺手量一次缓存起来。
96
+ */
97
+ this.fileBytes = 0
98
+ /** 是否有一次 flush 正在执行(防止两次写同时用同一个临时文件)。 */
99
+ this.flushing = false
100
+ }
101
+
102
+ /** 从磁盘加载历史数据;不存在或损坏时从空开始。 */
103
+ async load() {
104
+ if (this.loaded) return
105
+ this.loaded = true
106
+ try {
107
+ // 优先读当前文件名;读不到再依次试历史文件名(只读迁移)。
108
+ let source = this.file
109
+ // 顺手量一下文件大小,供 snapshot 展示(那里不能 await)。
110
+ this.fileBytes = await stat(source).then(info => info.size).catch(() => 0)
111
+ let raw
112
+ try {
113
+ raw = await readFile(source, 'utf8')
114
+ } catch {
115
+ for (const candidate of this.legacyFiles) {
116
+ try {
117
+ raw = await readFile(candidate, 'utf8')
118
+ source = candidate
119
+ this.fileBytes = await stat(candidate).then(info => info.size).catch(() => 0)
120
+ break
121
+ } catch {
122
+ // 该历史文件不存在,试下一个
123
+ }
124
+ }
125
+ if (raw === undefined) return
126
+ }
127
+ const doc = JSON.parse(raw)
128
+ if (doc?.version !== FILE_VERSION || !Array.isArray(doc.buckets)) return
129
+ for (const b of doc.buckets) {
130
+ if (typeof b?.scope !== 'string') continue
131
+ this.buckets.set(`${b.scope}|${b.accountId}|${b.model}`, {
132
+ ...newBucket(b.scope, b.accountId, b.model),
133
+ ...b,
134
+ })
135
+ }
136
+ } catch {
137
+ // 文件不存在或损坏:从空开始,不阻塞插件
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 记一次请求尝试。
143
+ *
144
+ * @param {object} entry
145
+ * @param {string} entry.accountId 用了哪个账号
146
+ * @param {string} entry.model 请求的模型
147
+ * @param {boolean} entry.ok 这次尝试是否成功(拿到 usage 即视为成功)
148
+ * @param {object} [entry.usage] 上游返回的 usage 对象
149
+ * @param {number} [entry.latencyMs] 耗时
150
+ */
151
+ add({ accountId, model, ok, usage, latencyMs }) {
152
+ const at = new Date(this.now())
153
+ const scope = `h:${hourKey(at)}`
154
+ const account = accountId || '(unknown)'
155
+ const modelId = model || '(unknown)'
156
+ const key = `${scope}|${account}|${modelId}`
157
+
158
+ let bucket = this.buckets.get(key)
159
+ if (bucket === undefined) {
160
+ bucket = newBucket(scope, account, modelId)
161
+ this.buckets.set(key, bucket)
162
+ }
163
+
164
+ bucket.requests += 1
165
+ if (!ok) bucket.errors += 1
166
+
167
+ const prompt = Number(usage?.prompt_tokens)
168
+ const completion = Number(usage?.completion_tokens)
169
+ const total = Number(usage?.total_tokens)
170
+ const credit = Number(usage?.credit)
171
+
172
+ if (Number.isFinite(prompt)) bucket.promptTokens += prompt
173
+ if (Number.isFinite(completion)) bucket.completionTokens += completion
174
+ // 上游没给 total 时用 pt+ct 兜底,保证总量口径连续。
175
+ if (Number.isFinite(total)) bucket.totalTokens += total
176
+ else if (Number.isFinite(prompt) || Number.isFinite(completion)) {
177
+ bucket.totalTokens += (Number.isFinite(prompt) ? prompt : 0) + (Number.isFinite(completion) ? completion : 0)
178
+ }
179
+ if (Number.isFinite(credit)) bucket.credits += credit
180
+
181
+ if (Number.isFinite(latencyMs) && latencyMs > 0) {
182
+ bucket.latencySumMs += latencyMs
183
+ bucket.latencySamples += 1
184
+ // 吐字速率只有拿到 completion 才有意义
185
+ if (Number.isFinite(completion) && completion >= 0) {
186
+ bucket.tpsSum += (completion * 1000) / latencyMs
187
+ bucket.tpsSamples += 1
188
+ }
189
+ }
190
+
191
+ this.dirty = true
192
+ this.scheduleFlush()
193
+ if (this.buckets.size > MAX_BUCKETS) this.rollup()
194
+ }
195
+
196
+ /**
197
+ * 把「早于保留期」的小时桶折叠为日桶。幂等:先累加再删源桶。
198
+ *
199
+ * 调用时机:仅在桶数超过 MAX_BUCKETS 时(见 add)。所以短期内桶数远低于
200
+ * 上限时不会折叠——这与参照实现一致,靠上限保护内存而非定时整理。
201
+ */
202
+ rollup() {
203
+ const cutoff = this.now() - HOURLY_KEEP_MS
204
+ for (const [key, bucket] of [...this.buckets]) {
205
+ if (!bucket.scope.startsWith('h:')) continue
206
+ // scope 形如 "h:2026-09-18T15",补上秒后按本地时间解析
207
+ const parsed = Date.parse(bucket.scope.slice(2) + ':00:00')
208
+ if (Number.isNaN(parsed) || parsed >= cutoff) continue
209
+
210
+ const dayScope = `d:${dayKey(new Date(parsed))}`
211
+ const dayKeyStr = `${dayScope}|${bucket.accountId}|${bucket.model}`
212
+ let target = this.buckets.get(dayKeyStr)
213
+ if (target === undefined) {
214
+ target = newBucket(dayScope, bucket.accountId, bucket.model)
215
+ this.buckets.set(dayKeyStr, target)
216
+ }
217
+ target.requests += bucket.requests
218
+ target.errors += bucket.errors
219
+ target.promptTokens += bucket.promptTokens
220
+ target.completionTokens += bucket.completionTokens
221
+ target.totalTokens += bucket.totalTokens
222
+ target.latencySumMs += bucket.latencySumMs
223
+ target.latencySamples += bucket.latencySamples
224
+ target.tpsSum += bucket.tpsSum
225
+ target.tpsSamples += bucket.tpsSamples
226
+ target.credits += bucket.credits
227
+ this.buckets.delete(key)
228
+ this.dirty = true
229
+ }
230
+ }
231
+
232
+ /** 安排一次防抖落盘。 */
233
+ scheduleFlush() {
234
+ if (this.timer !== null) return
235
+ this.timer = setTimeout(() => {
236
+ this.timer = null
237
+ void this.flush()
238
+ }, FLUSH_INTERVAL_MS)
239
+ // 定时器不该阻止进程退出
240
+ this.timer.unref?.()
241
+ }
242
+
243
+ /** 落盘(原子替换)。失败只记日志,不抛错。 */
244
+ async flush() {
245
+ // 已在写就跳过:dirty 标志本身不足以完全防重入
246
+ // (两次 add 穿插时仍可能同时进入),显式标志更稳。
247
+ if (this.flushing || !this.dirty) return
248
+ this.flushing = true
249
+ this.dirty = false
250
+ try {
251
+ await mkdir(dirname(this.file), { recursive: true })
252
+ const doc = {
253
+ version: FILE_VERSION,
254
+ saved: new Date(this.now()).toISOString(),
255
+ buckets: [...this.buckets.values()],
256
+ }
257
+ // 临时文件名带唯一后缀:即使有绕过 flushing 的路径也不会踩踏
258
+ const tmp = `${this.file}.${process.pid}.${++tmpSeq}.tmp`
259
+ const payload = JSON.stringify(doc)
260
+ await writeFile(tmp, payload, { encoding: 'utf8', mode: 0o600 })
261
+ await rename(tmp, this.file)
262
+ // 用实际写入的字节数(比再 stat 一次少一次系统调用)
263
+ this.fileBytes = Buffer.byteLength(payload, 'utf8')
264
+ } catch {
265
+ // 落盘失败不该影响请求处理;下次 add 会再触发
266
+ this.dirty = true
267
+ } finally {
268
+ this.flushing = false
269
+ }
270
+ }
271
+
272
+ /** 停止定时器并立即落盘(插件卸载时调用)。 */
273
+ async close() {
274
+ if (this.timer !== null) {
275
+ clearTimeout(this.timer)
276
+ this.timer = null
277
+ }
278
+ await this.flush()
279
+ }
280
+
281
+ /**
282
+ * 聚合出视图数据。
283
+ *
284
+ * @param {number} hours 时序返回多少个小时点(默认 72)
285
+ * @returns {{totals, byAccount, byModel, series, buckets, credits}}
286
+ */
287
+ snapshot(hours = 72) {
288
+ const window = hours > 0 && hours <= 24 * 60 ? hours : 72
289
+ const cutoff = this.now() - window * 3600 * 1000
290
+
291
+ const totals = newAcc()
292
+ const byAccount = new Map()
293
+ const byModel = new Map()
294
+ const series = []
295
+
296
+ for (const bucket of this.buckets.values()) {
297
+ const parsed = bucket.scope.startsWith('h:')
298
+ ? Date.parse(bucket.scope.slice(2) + ':00:00')
299
+ : Date.parse(bucket.scope.slice(2) + 'T00:00:00')
300
+
301
+ // 总量按全部桶算(含日桶),时序只取窗口内的点
302
+ addTo(totals, bucket)
303
+
304
+ const accountAcc = byAccount.get(bucket.accountId) ?? newAcc()
305
+ addTo(accountAcc, bucket)
306
+ byAccount.set(bucket.accountId, accountAcc)
307
+
308
+ const modelAcc = byModel.get(bucket.model) ?? newAcc()
309
+ addTo(modelAcc, bucket)
310
+ byModel.set(bucket.model, modelAcc)
311
+
312
+ if (!Number.isNaN(parsed) && parsed >= cutoff) {
313
+ series.push({
314
+ t: bucket.scope.slice(2),
315
+ kind: bucket.scope.startsWith('h:') ? 'hour' : 'day',
316
+ accountId: bucket.accountId,
317
+ model: bucket.model,
318
+ ...finish(bucketAccToAgg(bucket)),
319
+ })
320
+ }
321
+ }
322
+
323
+ series.sort((a, b) => a.t.localeCompare(b.t))
324
+
325
+ return {
326
+ totals: finish(totals),
327
+ byAccount: [...byAccount].map(([key, acc]) => ({ key, ...finish(acc) })).sort((a, b) => b.totalTokens - a.totalTokens),
328
+ byModel: [...byModel].map(([key, acc]) => ({ key, ...finish(acc) })).sort((a, b) => b.totalTokens - a.totalTokens),
329
+ series,
330
+ buckets: this.buckets.size,
331
+ fileBytes: this.fileBytes,
332
+ // 最早的分片即数据起点(只在有数据时给)
333
+ ...(series.length === 0 ? {} : { since: series[0].t }),
334
+ }
335
+ }
336
+ }
337
+
338
+ /** 新建一个累加器;均值需要样本数才能正确加权,不能对每桶均值再取平均。 */
339
+ function newAcc() {
340
+ return {
341
+ requests: 0, errors: 0,
342
+ promptTokens: 0, completionTokens: 0, totalTokens: 0, credits: 0,
343
+ latencySumMs: 0, latencySamples: 0,
344
+ tpsSum: 0, tpsSamples: 0,
345
+ }
346
+ }
347
+
348
+ /** 一个桶直接转成累加器形状。 */
349
+ function bucketAccToAgg(bucket) {
350
+ return {
351
+ requests: bucket.requests,
352
+ errors: bucket.errors,
353
+ promptTokens: bucket.promptTokens,
354
+ completionTokens: bucket.completionTokens,
355
+ totalTokens: bucket.totalTokens,
356
+ credits: bucket.credits,
357
+ latencySumMs: bucket.latencySumMs,
358
+ latencySamples: bucket.latencySamples,
359
+ tpsSum: bucket.tpsSum,
360
+ tpsSamples: bucket.tpsSamples,
361
+ }
362
+ }
363
+
364
+ function addTo(acc, bucket) {
365
+ acc.requests += bucket.requests
366
+ acc.errors += bucket.errors
367
+ acc.promptTokens += bucket.promptTokens
368
+ acc.completionTokens += bucket.completionTokens
369
+ acc.totalTokens += bucket.totalTokens
370
+ acc.credits += bucket.credits
371
+ acc.latencySumMs += bucket.latencySumMs
372
+ acc.latencySamples += bucket.latencySamples
373
+ acc.tpsSum += bucket.tpsSum
374
+ acc.tpsSamples += bucket.tpsSamples
375
+ }
376
+
377
+ /** 算出均值并去掉中间累加字段。 */
378
+ function finish(acc) {
379
+ return {
380
+ requests: acc.requests,
381
+ errors: acc.errors,
382
+ promptTokens: acc.promptTokens,
383
+ completionTokens: acc.completionTokens,
384
+ totalTokens: acc.totalTokens,
385
+ credits: acc.credits,
386
+ avgLatencyMs: acc.latencySamples > 0 ? acc.latencySumMs / acc.latencySamples : 0,
387
+ avgTokensPerSecond: acc.tpsSamples > 0 ? acc.tpsSum / acc.tpsSamples : 0,
388
+ // 延迟与速率的样本数:它们只统计**成功**请求,可能远少于请求总数。
389
+ // 前端靠它区分「没有成功请求」(显示 —)与「就是很快」(显示真实值)。
390
+ latencySamples: acc.latencySamples,
391
+ }
392
+ }
393
+
394
+ /**
395
+ * 从 SSE 数据帧里提取 usage 与 model。
396
+ *
397
+ * 上游在流末尾发一帧带 usage 的数据(含 credit 积分消耗)。
398
+ * 这里只扫文本,不需要完整解析 SSE——目标字段明确且只出现在数据行。
399
+ *
400
+ * @param {string} text 一帧或一段 SSE 文本
401
+ * @returns {{usage?:object, model?:string}|undefined}
402
+ */
403
+ export function extractUsage(text) {
404
+ if (typeof text !== 'string' || !text.includes('"usage"')) return undefined
405
+ for (const line of text.split('\n')) {
406
+ const trimmed = line.trim()
407
+ if (!trimmed.startsWith('data:')) continue
408
+ const payload = trimmed.slice(5).trim()
409
+ if (payload === '' || payload === '[DONE]') continue
410
+ try {
411
+ const frame = JSON.parse(payload)
412
+ if (frame?.usage && typeof frame.usage === 'object') {
413
+ return { usage: frame.usage, model: typeof frame.model === 'string' ? frame.model : undefined }
414
+ }
415
+ } catch {
416
+ // 不完整帧:忽略,后续 chunk 会补齐
417
+ }
418
+ }
419
+ return undefined
420
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "dsh-account-pool",
3
+ "displayName": "Account Pool",
4
+ "version": "0.1.0",
5
+ "description": "把多个 WorkBuddy / Trae 账号汇成账号池接入 DeepSeek Harness:自动切换、限流熔断退避、凭证安全落盘",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cliii-one/dsh-account-pool.git"
11
+ },
12
+ "homepage": "https://github.com/cliii-one/dsh-account-pool#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/cliii-one/dsh-account-pool/issues"
15
+ },
16
+ "author": "cliii-one",
17
+ "main": "lib/index.js",
18
+ "exports": {
19
+ ".": {
20
+ "default": "./lib/index.js"
21
+ },
22
+ "./client": "./lib/client.js",
23
+ "./cordis.patch.yml": "./cordis.patch.yml",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "lib",
28
+ "cordis.patch.yml",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": "^22.19.0 || >=24.0.0"
33
+ },
34
+ "scripts": {
35
+ "check": "node scripts/check.mjs"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-ui-slots"
45
+ ]
46
+ }
47
+ },
48
+ "keywords": [
49
+ "dsh",
50
+ "dsh-plugin",
51
+ "deepseek-harness",
52
+ "workbuddy",
53
+ "codebuddy",
54
+ "model-provider",
55
+ "account-pool",
56
+ "trae",
57
+ "trae-solo"
58
+ ],
59
+ "peerDependencies": {
60
+ "@deepseek-ai/cordis": ">=4.0.1 <5.0.0",
61
+ "@deepseek-ai/dsh-llm": "^0.1.6-alpha.2",
62
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.6-alpha.2",
63
+ "@earendil-works/pi-ai": "^0.85.0"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }