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/README.md +362 -0
- package/cordis.patch.yml +26 -0
- package/lib/accounts.js +576 -0
- package/lib/client.js +2049 -0
- package/lib/headers.js +76 -0
- package/lib/index.js +1142 -0
- package/lib/selector.js +343 -0
- package/lib/shim.js +370 -0
- package/lib/tasks.js +429 -0
- package/lib/trae-accounts.js +453 -0
- package/lib/trae-bridge.js +266 -0
- package/lib/trae-storage.js +215 -0
- package/lib/trae-upstream.js +485 -0
- package/lib/upstream.js +610 -0
- package/lib/usage.js +420 -0
- package/package.json +68 -0
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
|
+
}
|