dsh-all-usage 1.0.4 → 1.0.6
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/LICENSE +21 -21
- package/README.md +10 -6
- package/cordis.patch.yml +5 -5
- package/lib/client.js +1200 -1063
- package/lib/index.js +958 -630
- package/package.json +54 -54
package/lib/index.js
CHANGED
|
@@ -1,630 +1,958 @@
|
|
|
1
|
-
// dsh-all-usage 插件 Host 半(永久版)
|
|
2
|
-
// 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
|
|
3
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
const name = 'dsh-all-usage'
|
|
6
|
-
const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
|
|
7
|
-
|
|
8
|
-
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
|
-
// local and require a browser-originated capability for state-changing reads/writes.
|
|
10
|
-
function requestHeader(req, name) {
|
|
11
|
-
const headers = req && req.headers
|
|
12
|
-
if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
|
|
13
|
-
const value = headers[name.toLowerCase()]
|
|
14
|
-
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
|
|
15
|
-
return typeof value === 'string' ? value : undefined
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function isLoopbackHostname(hostname) {
|
|
19
|
-
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
20
|
-
const parts = hostname.split('.')
|
|
21
|
-
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isTrustedLocalApiRequest(req, requireOrigin) {
|
|
25
|
-
const host = requestHeader(req, 'host')
|
|
26
|
-
if (host === undefined) return false
|
|
27
|
-
let hostUrl
|
|
28
|
-
try {
|
|
29
|
-
hostUrl = new URL('http://' + host)
|
|
30
|
-
} catch (err) {
|
|
31
|
-
return false
|
|
32
|
-
}
|
|
33
|
-
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
34
|
-
if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
|
|
35
|
-
const origin = requestHeader(req, 'origin')
|
|
36
|
-
if (origin === undefined) return requireOrigin !== true
|
|
37
|
-
try {
|
|
38
|
-
const originUrl = new URL(origin)
|
|
39
|
-
return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
|
|
40
|
-
} catch (err) {
|
|
41
|
-
return false
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function hasWriteToken(req, expected) {
|
|
46
|
-
const actual = requestHeader(req, 'x-all-usage-request-token')
|
|
47
|
-
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
48
|
-
const actualBytes = Buffer.from(actual)
|
|
49
|
-
const expectedBytes = Buffer.from(expected)
|
|
50
|
-
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sendJson(res, code, value) {
|
|
54
|
-
res.statusCode = code
|
|
55
|
-
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
56
|
-
res.setHeader('cache-control', 'no-store')
|
|
57
|
-
res.end(JSON.stringify(value))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readBody(req, maxBytes) {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
const chunks = []
|
|
63
|
-
let size = 0
|
|
64
|
-
req.on('data', (chunk) => {
|
|
65
|
-
size += chunk.length
|
|
66
|
-
if (size <= maxBytes) chunks.push(chunk)
|
|
67
|
-
})
|
|
68
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
69
|
-
req.on('error', () => resolve(''))
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function apply(ctx) {
|
|
74
|
-
const credentials = ctx.get('credentials')
|
|
75
|
-
const settings = ctx.get('settings')
|
|
76
|
-
const storage = ctx.get('storage')
|
|
77
|
-
const webServer = ctx.get('webServer')
|
|
78
|
-
|
|
79
|
-
// ---------- owned aggregation state ----------
|
|
80
|
-
const wsMeta = new Map()
|
|
81
|
-
const pathIndex = new Map()
|
|
82
|
-
const memberOf = new Map()
|
|
83
|
-
const byDay = new Map()
|
|
84
|
-
const byDayUtc = new Map()
|
|
85
|
-
const perWorkspace = new Map()
|
|
86
|
-
const perModel = new Map()
|
|
87
|
-
// One canonical usage contribution per session turn/step. This makes retries and
|
|
88
|
-
// replacement messages update a logical model call instead of double-counting it.
|
|
89
|
-
const usageByStep = new Map()
|
|
90
|
-
const sessionModel = new Map()
|
|
91
|
-
const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
92
|
-
const sessionCount = new Set()
|
|
93
|
-
const sessionSeq = new Map()
|
|
94
|
-
const chains = new Map()
|
|
95
|
-
const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
|
|
96
|
-
const aliases = {}
|
|
97
|
-
let kvUnit = null
|
|
98
|
-
let aliasWriteChain = Promise.resolve()
|
|
99
|
-
let balanceCache = { fetchedAt: 0, payload: null }
|
|
100
|
-
const requestToken = randomBytes(32).toString('base64url')
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
function
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
return
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (type
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
let
|
|
503
|
-
try {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1
|
+
// dsh-all-usage 插件 Host 半(永久版)
|
|
2
|
+
// 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
|
|
3
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const name = 'dsh-all-usage'
|
|
6
|
+
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
|
|
7
|
+
|
|
8
|
+
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
|
+
// local and require a browser-originated capability for state-changing reads/writes.
|
|
10
|
+
function requestHeader(req, name) {
|
|
11
|
+
const headers = req && req.headers
|
|
12
|
+
if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
|
|
13
|
+
const value = headers[name.toLowerCase()]
|
|
14
|
+
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
|
|
15
|
+
return typeof value === 'string' ? value : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLoopbackHostname(hostname) {
|
|
19
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
20
|
+
const parts = hostname.split('.')
|
|
21
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isTrustedLocalApiRequest(req, requireOrigin) {
|
|
25
|
+
const host = requestHeader(req, 'host')
|
|
26
|
+
if (host === undefined) return false
|
|
27
|
+
let hostUrl
|
|
28
|
+
try {
|
|
29
|
+
hostUrl = new URL('http://' + host)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
34
|
+
if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
|
|
35
|
+
const origin = requestHeader(req, 'origin')
|
|
36
|
+
if (origin === undefined) return requireOrigin !== true
|
|
37
|
+
try {
|
|
38
|
+
const originUrl = new URL(origin)
|
|
39
|
+
return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasWriteToken(req, expected) {
|
|
46
|
+
const actual = requestHeader(req, 'x-all-usage-request-token')
|
|
47
|
+
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
48
|
+
const actualBytes = Buffer.from(actual)
|
|
49
|
+
const expectedBytes = Buffer.from(expected)
|
|
50
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendJson(res, code, value) {
|
|
54
|
+
res.statusCode = code
|
|
55
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
56
|
+
res.setHeader('cache-control', 'no-store')
|
|
57
|
+
res.end(JSON.stringify(value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readBody(req, maxBytes) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const chunks = []
|
|
63
|
+
let size = 0
|
|
64
|
+
req.on('data', (chunk) => {
|
|
65
|
+
size += chunk.length
|
|
66
|
+
if (size <= maxBytes) chunks.push(chunk)
|
|
67
|
+
})
|
|
68
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
69
|
+
req.on('error', () => resolve(''))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function apply(ctx) {
|
|
74
|
+
const credentials = ctx.get('credentials')
|
|
75
|
+
const settings = ctx.get('settings')
|
|
76
|
+
const storage = ctx.get('storage')
|
|
77
|
+
const webServer = ctx.get('webServer')
|
|
78
|
+
|
|
79
|
+
// ---------- owned aggregation state ----------
|
|
80
|
+
const wsMeta = new Map()
|
|
81
|
+
const pathIndex = new Map()
|
|
82
|
+
const memberOf = new Map()
|
|
83
|
+
const byDay = new Map()
|
|
84
|
+
const byDayUtc = new Map()
|
|
85
|
+
const perWorkspace = new Map()
|
|
86
|
+
const perModel = new Map()
|
|
87
|
+
// One canonical usage contribution per session turn/step. This makes retries and
|
|
88
|
+
// replacement messages update a logical model call instead of double-counting it.
|
|
89
|
+
const usageByStep = new Map()
|
|
90
|
+
const sessionModel = new Map()
|
|
91
|
+
const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
92
|
+
const sessionCount = new Set()
|
|
93
|
+
const sessionSeq = new Map()
|
|
94
|
+
const chains = new Map()
|
|
95
|
+
const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
|
|
96
|
+
const aliases = {}
|
|
97
|
+
let kvUnit = null
|
|
98
|
+
let aliasWriteChain = Promise.resolve()
|
|
99
|
+
let balanceCache = { fetchedAt: 0, payload: null }
|
|
100
|
+
const requestToken = randomBytes(32).toString('base64url')
|
|
101
|
+
const ledgerRecords = new Map()
|
|
102
|
+
let ledgerUnit = null
|
|
103
|
+
let ledgerReady = Promise.resolve()
|
|
104
|
+
let ledgerWriteChain = Promise.resolve()
|
|
105
|
+
const LEDGER_VERSION = 1
|
|
106
|
+
let ledgerRevision = Date.now()
|
|
107
|
+
let disposed = false
|
|
108
|
+
let baselineRetryDelay = 1000
|
|
109
|
+
let baselineRetryScheduled = false
|
|
110
|
+
let baselineFallbackTimer = null
|
|
111
|
+
const knownSessionIds = new Set()
|
|
112
|
+
let aggregationGeneration = 0
|
|
113
|
+
let reconcileHintScheduled = false
|
|
114
|
+
let reconcilePending = false
|
|
115
|
+
let reconcileInFlight = false
|
|
116
|
+
let reconcileTimer = null
|
|
117
|
+
const RECONCILE_INTERVAL_MS = 120000
|
|
118
|
+
const RECONCILE_HINT_DELAY_MS = 3000
|
|
119
|
+
|
|
120
|
+
function dayKey(ms) {
|
|
121
|
+
const d = new Date(ms)
|
|
122
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
|
|
123
|
+
}
|
|
124
|
+
function dayKeyUtc(ms) {
|
|
125
|
+
const d = new Date(ms)
|
|
126
|
+
return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
|
|
127
|
+
}
|
|
128
|
+
function num(v) {
|
|
129
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
130
|
+
}
|
|
131
|
+
async function safeContextTimeout(ms) {
|
|
132
|
+
if (disposed) return false
|
|
133
|
+
try {
|
|
134
|
+
await ctx.timeout(ms)
|
|
135
|
+
return !disposed
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (!disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resetAggregationState() {
|
|
142
|
+
aggregationGeneration += 1
|
|
143
|
+
wsMeta.clear()
|
|
144
|
+
pathIndex.clear()
|
|
145
|
+
memberOf.clear()
|
|
146
|
+
byDay.clear()
|
|
147
|
+
byDayUtc.clear()
|
|
148
|
+
perWorkspace.clear()
|
|
149
|
+
perModel.clear()
|
|
150
|
+
usageByStep.clear()
|
|
151
|
+
sessionModel.clear()
|
|
152
|
+
sessionCount.clear()
|
|
153
|
+
sessionSeq.clear()
|
|
154
|
+
chains.clear()
|
|
155
|
+
knownSessionIds.clear()
|
|
156
|
+
totals.turns = 0
|
|
157
|
+
totals.input = 0
|
|
158
|
+
totals.output = 0
|
|
159
|
+
totals.cacheRead = 0
|
|
160
|
+
totals.cacheWrite = 0
|
|
161
|
+
totals.reasoning = 0
|
|
162
|
+
scan.started = false
|
|
163
|
+
scan.done = false
|
|
164
|
+
scan.scanned = 0
|
|
165
|
+
scan.total = 0
|
|
166
|
+
scan.failed = 0
|
|
167
|
+
baselineRetryDelay = 1000
|
|
168
|
+
return aggregationGeneration
|
|
169
|
+
}
|
|
170
|
+
function ensureDay(dayMap, date) {
|
|
171
|
+
let day = dayMap.get(date)
|
|
172
|
+
if (day === undefined) {
|
|
173
|
+
day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
|
|
174
|
+
dayMap.set(date, day)
|
|
175
|
+
}
|
|
176
|
+
return day
|
|
177
|
+
}
|
|
178
|
+
function ensureWs(wsId) {
|
|
179
|
+
let ws = perWorkspace.get(wsId)
|
|
180
|
+
if (ws === undefined) {
|
|
181
|
+
ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
182
|
+
perWorkspace.set(wsId, ws)
|
|
183
|
+
}
|
|
184
|
+
return ws
|
|
185
|
+
}
|
|
186
|
+
function ensureDayWs(day, wsId) {
|
|
187
|
+
let w = day.byWs.get(wsId)
|
|
188
|
+
if (w === undefined) {
|
|
189
|
+
w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
190
|
+
day.byWs.set(wsId, w)
|
|
191
|
+
}
|
|
192
|
+
return w
|
|
193
|
+
}
|
|
194
|
+
function ensureModel(model) {
|
|
195
|
+
let item = perModel.get(model)
|
|
196
|
+
if (item === undefined) {
|
|
197
|
+
item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
198
|
+
perModel.set(model, item)
|
|
199
|
+
}
|
|
200
|
+
return item
|
|
201
|
+
}
|
|
202
|
+
function ensureDayModel(day, model) {
|
|
203
|
+
let item = day.byModel.get(model)
|
|
204
|
+
if (item === undefined) {
|
|
205
|
+
item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
|
|
206
|
+
day.byModel.set(model, item)
|
|
207
|
+
}
|
|
208
|
+
return item
|
|
209
|
+
}
|
|
210
|
+
function usageValues(usage) {
|
|
211
|
+
return {
|
|
212
|
+
input: num(usage && usage.inputTokens),
|
|
213
|
+
output: num(usage && usage.outputTokens),
|
|
214
|
+
cacheRead: num(usage && usage.cacheReadTokens),
|
|
215
|
+
cacheWrite: num(usage && usage.cacheWriteTokens),
|
|
216
|
+
reasoning: num(usage && usage.reasoningTokens),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function adjustValues(target, values, direction) {
|
|
220
|
+
target.input += values.input * direction
|
|
221
|
+
target.output += values.output * direction
|
|
222
|
+
target.cacheRead += values.cacheRead * direction
|
|
223
|
+
target.cacheWrite += values.cacheWrite * direction
|
|
224
|
+
target.reasoning += values.reasoning * direction
|
|
225
|
+
}
|
|
226
|
+
function noValues(target) {
|
|
227
|
+
return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
|
|
228
|
+
}
|
|
229
|
+
function adjustDay(dayMap, date, wsId, values, modelId, direction, sid) {
|
|
230
|
+
const day = ensureDay(dayMap, date)
|
|
231
|
+
if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
|
|
232
|
+
adjustValues(day.tokens, values, direction)
|
|
233
|
+
const dayWs = ensureDayWs(day, wsId)
|
|
234
|
+
adjustValues(dayWs, values, direction)
|
|
235
|
+
if (noValues(dayWs)) day.byWs.delete(wsId)
|
|
236
|
+
const dayModel = ensureDayModel(day, modelId)
|
|
237
|
+
dayModel.calls += direction
|
|
238
|
+
adjustValues(dayModel, values, direction)
|
|
239
|
+
if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(modelId)
|
|
240
|
+
}
|
|
241
|
+
function adjustUsage(wsId, time, values, modelId, direction, sid) {
|
|
242
|
+
const modelTotals = ensureModel(modelId)
|
|
243
|
+
modelTotals.calls += direction
|
|
244
|
+
adjustValues(modelTotals, values, direction)
|
|
245
|
+
if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(modelId)
|
|
246
|
+
adjustValues(totals, values, direction)
|
|
247
|
+
const ws = ensureWs(wsId)
|
|
248
|
+
adjustValues(ws, values, direction)
|
|
249
|
+
adjustDay(byDay, dayKey(time), wsId, values, modelId, direction, sid)
|
|
250
|
+
adjustDay(byDayUtc, dayKeyUtc(time), wsId, values, modelId, direction, sid)
|
|
251
|
+
}
|
|
252
|
+
function usageStepKey(sid, data, seq) {
|
|
253
|
+
const turn = data && typeof data.turn === 'number' ? data.turn : null
|
|
254
|
+
const step = data && typeof data.step === 'number' ? data.step : null
|
|
255
|
+
if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
|
|
256
|
+
return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
|
|
257
|
+
}
|
|
258
|
+
function addUsage(wsId, time, usage, model, sid, data, seq) {
|
|
259
|
+
const values = usageValues(usage)
|
|
260
|
+
const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
|
|
261
|
+
const eventSeq = typeof seq === 'number' ? seq : -1
|
|
262
|
+
const key = usageStepKey(sid, data, seq)
|
|
263
|
+
const previous = usageByStep.get(key)
|
|
264
|
+
// A late replay of an older raw event cannot replace the canonical later step.
|
|
265
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
|
|
266
|
+
if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.modelId, -1, previous.sid)
|
|
267
|
+
const next = { seq: eventSeq, wsId, time, values, modelId, sid }
|
|
268
|
+
usageByStep.set(key, next)
|
|
269
|
+
adjustUsage(wsId, time, values, modelId, 1, sid)
|
|
270
|
+
}
|
|
271
|
+
function addDayTurn(dayMap, date, wsId, sid) {
|
|
272
|
+
const day = ensureDay(dayMap, date)
|
|
273
|
+
if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
|
|
274
|
+
day.turns += 1
|
|
275
|
+
day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
|
|
276
|
+
}
|
|
277
|
+
function addTurn(wsId, time, sid) {
|
|
278
|
+
ensureWs(wsId).turns += 1
|
|
279
|
+
totals.turns += 1
|
|
280
|
+
addDayTurn(byDay, dayKey(time), wsId, sid)
|
|
281
|
+
addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
|
|
282
|
+
}
|
|
283
|
+
function routeLabel(route) {
|
|
284
|
+
if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
function modelFromRoute(data) {
|
|
288
|
+
return routeLabel(data) || routeLabel(data && data.header && data.header.config)
|
|
289
|
+
}
|
|
290
|
+
function modelFromMessage(data, fallback) {
|
|
291
|
+
return routeLabel(data && data.message && data.message.source) || fallback
|
|
292
|
+
}
|
|
293
|
+
function nextLedgerRevision() {
|
|
294
|
+
ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
|
|
295
|
+
return ledgerRevision
|
|
296
|
+
}
|
|
297
|
+
function ledgerEventKey(event, index) {
|
|
298
|
+
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
299
|
+
}
|
|
300
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan') {
|
|
301
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
302
|
+
const events = session && Array.isArray(session.events) ? session.events : []
|
|
303
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
304
|
+
const turns = new Map()
|
|
305
|
+
const usage = new Map()
|
|
306
|
+
let currentModel
|
|
307
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
308
|
+
const event = events[index]
|
|
309
|
+
if (event === null || typeof event !== 'object') continue
|
|
310
|
+
const data = event.data
|
|
311
|
+
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
312
|
+
const model = modelFromRoute(data)
|
|
313
|
+
if (model !== undefined) currentModel = model
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if (event.type === 'turn/end') {
|
|
317
|
+
const key = ledgerEventKey(event, index)
|
|
318
|
+
turns.set(key, { key, time: event.time, workspaceId })
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
|
|
322
|
+
const values = usageValues(data.usage)
|
|
323
|
+
const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
|
|
324
|
+
? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
|
|
325
|
+
const eventSeq = typeof event.seq === 'number' ? event.seq : -1
|
|
326
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
327
|
+
const previous = usage.get(key)
|
|
328
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
329
|
+
usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
|
|
330
|
+
}
|
|
331
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
332
|
+
}
|
|
333
|
+
function normalizeLedgerRecord(raw, key) {
|
|
334
|
+
if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
335
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
336
|
+
const turnMap = new Map()
|
|
337
|
+
for (const turn of raw.turns) {
|
|
338
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
|
|
339
|
+
}
|
|
340
|
+
const usageMap = new Map()
|
|
341
|
+
for (const item of raw.usage) {
|
|
342
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
343
|
+
const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
344
|
+
const previous = usageMap.get(normalized.key)
|
|
345
|
+
if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
|
|
346
|
+
}
|
|
347
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
348
|
+
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
349
|
+
return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
350
|
+
}
|
|
351
|
+
function applyLedgerRecord(record) {
|
|
352
|
+
if (record === null || record === undefined) return
|
|
353
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
|
|
354
|
+
for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
|
|
355
|
+
if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
|
|
356
|
+
}
|
|
357
|
+
function ledgerRank(record) {
|
|
358
|
+
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
359
|
+
}
|
|
360
|
+
function storeLedgerRecord(record) {
|
|
361
|
+
const current = ledgerRecords.get(record.sessionId)
|
|
362
|
+
if (current !== undefined) {
|
|
363
|
+
const nextRank = ledgerRank(record)
|
|
364
|
+
const currentRank = ledgerRank(current)
|
|
365
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
366
|
+
}
|
|
367
|
+
ledgerRecords.set(record.sessionId, record)
|
|
368
|
+
return record
|
|
369
|
+
}
|
|
370
|
+
function persistLedgerRecord(record) {
|
|
371
|
+
if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
372
|
+
const write = ledgerWriteChain.then(async () => {
|
|
373
|
+
if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
|
|
374
|
+
})
|
|
375
|
+
ledgerWriteChain = write.catch((err) => {
|
|
376
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
377
|
+
})
|
|
378
|
+
return ledgerWriteChain
|
|
379
|
+
}
|
|
380
|
+
function foldEvent(wsId, time, type, data, sid, seq) {
|
|
381
|
+
if (type === 'request/context' || type === 'request/header') {
|
|
382
|
+
const model = modelFromRoute(data)
|
|
383
|
+
if (model !== undefined) sessionModel.set(sid, model)
|
|
384
|
+
} else if (type === 'turn/end') addTurn(wsId, time, sid)
|
|
385
|
+
else if (type === 'assistant/message' && data && data.usage) addUsage(wsId, time, data.usage, modelFromMessage(data, sessionModel.get(sid)), sid, data, seq)
|
|
386
|
+
}
|
|
387
|
+
function foldEvents(wsId, events, fromSeq, sid) {
|
|
388
|
+
for (const ev of events) {
|
|
389
|
+
if (fromSeq !== undefined) {
|
|
390
|
+
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
391
|
+
if (s <= fromSeq) continue
|
|
392
|
+
}
|
|
393
|
+
if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function lastSeqOf(events) {
|
|
397
|
+
let last = 0
|
|
398
|
+
for (const ev of events) {
|
|
399
|
+
const s = typeof ev.seq === 'number' ? ev.seq : -1
|
|
400
|
+
if (s > last) last = s
|
|
401
|
+
}
|
|
402
|
+
return last
|
|
403
|
+
}
|
|
404
|
+
function enqueue(sid, task) {
|
|
405
|
+
const prev = chains.get(sid) || Promise.resolve()
|
|
406
|
+
const next = prev.then(() => task(), () => task())
|
|
407
|
+
chains.set(sid, next)
|
|
408
|
+
return next
|
|
409
|
+
}
|
|
410
|
+
function wsForLiveSession(session, sid) {
|
|
411
|
+
let wsId = memberOf.get(sid)
|
|
412
|
+
if (wsId !== undefined) return wsId
|
|
413
|
+
const header = session && session.header
|
|
414
|
+
const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
|
|
415
|
+
if (cwd === '') return undefined
|
|
416
|
+
wsId = pathIndex.get(cwd)
|
|
417
|
+
if (wsId !== undefined) memberOf.set(sid, wsId)
|
|
418
|
+
return wsId
|
|
419
|
+
}
|
|
420
|
+
async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
|
|
421
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
422
|
+
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
423
|
+
const last = sessionSeq.get(sid)
|
|
424
|
+
if (last === undefined) {
|
|
425
|
+
try {
|
|
426
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
427
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
428
|
+
if (snap && Array.isArray(snap.events)) {
|
|
429
|
+
foldEvents(wsId, snap.events, undefined, sid)
|
|
430
|
+
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
431
|
+
sessionCount.add(sid)
|
|
432
|
+
}
|
|
433
|
+
} catch (err) { /* retry on the next event */ }
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (seq <= last) return
|
|
437
|
+
if (seq > last + 1) {
|
|
438
|
+
try {
|
|
439
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
440
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
441
|
+
if (snap && Array.isArray(snap.events)) {
|
|
442
|
+
foldEvents(wsId, snap.events, last, sid)
|
|
443
|
+
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
444
|
+
}
|
|
445
|
+
} catch (err) { /* keep last; retry later */ }
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
foldEvent(wsId, event.time, event.type, event.data, sid, event.seq)
|
|
449
|
+
sessionSeq.set(sid, seq)
|
|
450
|
+
sessionCount.add(sid)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------- durable usage ledger ----------
|
|
454
|
+
async function loadLedger() {
|
|
455
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
456
|
+
try {
|
|
457
|
+
const backend = storage.backend.get('json')
|
|
458
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
459
|
+
const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
|
|
460
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
461
|
+
ledgerUnit = unit
|
|
462
|
+
const snapshot = await unit.loadAll()
|
|
463
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
464
|
+
if (rows !== null && rows !== undefined && typeof rows === 'object') {
|
|
465
|
+
for (const [key, raw] of Object.entries(rows)) {
|
|
466
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
467
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
468
|
+
else ledgerRecords.set(key, record)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------- baseline scan over durable logs ----------
|
|
477
|
+
function scheduleNativeBaselineRetry(generation, delay) {
|
|
478
|
+
if (disposed || baselineFallbackTimer !== null) return
|
|
479
|
+
baselineFallbackTimer = setTimeout(() => {
|
|
480
|
+
baselineFallbackTimer = null
|
|
481
|
+
if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
|
|
482
|
+
}, delay)
|
|
483
|
+
if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
|
|
484
|
+
}
|
|
485
|
+
function scheduleBaselineRetry(generation = aggregationGeneration) {
|
|
486
|
+
if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
|
|
487
|
+
baselineRetryScheduled = true
|
|
488
|
+
const delay = baselineRetryDelay
|
|
489
|
+
baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
|
|
490
|
+
void safeContextTimeout(delay).then((ready) => {
|
|
491
|
+
baselineRetryScheduled = false
|
|
492
|
+
if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
|
|
493
|
+
if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
494
|
+
return undefined
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
async function runBaseline(generation = aggregationGeneration) {
|
|
498
|
+
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
499
|
+
scan.started = true
|
|
500
|
+
await ledgerReady
|
|
501
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
502
|
+
let setupFailed = false
|
|
503
|
+
try {
|
|
504
|
+
const workspaces = ctx.workspaceRegistry.list()
|
|
505
|
+
for (const w of workspaces) {
|
|
506
|
+
const id = w && w.id
|
|
507
|
+
const path = w && typeof w.path === 'string' ? w.path : ''
|
|
508
|
+
const title = w && typeof w.title === 'string' ? w.title : ''
|
|
509
|
+
if (id === undefined) continue
|
|
510
|
+
wsMeta.set(id, { id, title, path })
|
|
511
|
+
if (path !== '') pathIndex.set(path, id)
|
|
512
|
+
if (w && Array.isArray(w.sessionIds)) {
|
|
513
|
+
for (const sid of w.sessionIds) memberOf.set(sid, id)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} catch (err) {
|
|
517
|
+
console.error('[all-usage] workspace list failed:', err)
|
|
518
|
+
setupFailed = true
|
|
519
|
+
}
|
|
520
|
+
let records = null
|
|
521
|
+
try {
|
|
522
|
+
records = await ctx.sessionQuery.listSessions()
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error('[all-usage] session list failed:', err)
|
|
525
|
+
}
|
|
526
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
527
|
+
if (setupFailed || !Array.isArray(records)) {
|
|
528
|
+
// A transient registry failure must not be reported as a completed empty scan.
|
|
529
|
+
scan.started = false
|
|
530
|
+
scheduleBaselineRetry(generation)
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
scan.total = records.length
|
|
534
|
+
const listedSessionIds = new Set()
|
|
535
|
+
for (const record of records) {
|
|
536
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
537
|
+
const sid = record.header.id
|
|
538
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
539
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
540
|
+
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
541
|
+
}
|
|
542
|
+
for (const [sid, record] of ledgerRecords) {
|
|
543
|
+
if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
|
|
544
|
+
}
|
|
545
|
+
for (const record of records) {
|
|
546
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
547
|
+
if (record === undefined || record === null || record.header === undefined) {
|
|
548
|
+
scan.scanned += 1
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
const sid = record.header.id
|
|
552
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
553
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
554
|
+
if (sid === undefined || wsId === undefined) {
|
|
555
|
+
scan.scanned += 1
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
listedSessionIds.add(sid)
|
|
559
|
+
await enqueue(sid, async () => {
|
|
560
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
561
|
+
try {
|
|
562
|
+
if (sessionSeq.has(sid)) return
|
|
563
|
+
const snap = await ctx.sessionQuery.readSession(sid)
|
|
564
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
565
|
+
if (snap && Array.isArray(snap.events)) {
|
|
566
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
|
|
567
|
+
const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
|
|
568
|
+
if (canonical === ledger) {
|
|
569
|
+
void persistLedgerRecord(ledger)
|
|
570
|
+
foldEvents(wsId, snap.events, undefined, sid)
|
|
571
|
+
} else if (canonical !== undefined) {
|
|
572
|
+
applyLedgerRecord(canonical)
|
|
573
|
+
}
|
|
574
|
+
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
575
|
+
sessionCount.add(sid)
|
|
576
|
+
}
|
|
577
|
+
} catch (err) {
|
|
578
|
+
const saved = ledgerRecords.get(sid)
|
|
579
|
+
if (saved !== undefined) {
|
|
580
|
+
applyLedgerRecord(saved)
|
|
581
|
+
sessionSeq.set(sid, -1)
|
|
582
|
+
sessionCount.add(sid)
|
|
583
|
+
} else if (generation === aggregationGeneration) {
|
|
584
|
+
sessionSeq.set(sid, -1)
|
|
585
|
+
scan.failed += 1
|
|
586
|
+
}
|
|
587
|
+
} finally {
|
|
588
|
+
if (generation === aggregationGeneration) scan.scanned += 1
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
if (!(await safeContextTimeout(0))) {
|
|
592
|
+
scan.started = false
|
|
593
|
+
scheduleBaselineRetry(generation)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
598
|
+
await ledgerWriteChain
|
|
599
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
600
|
+
knownSessionIds.clear()
|
|
601
|
+
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
602
|
+
scan.done = true
|
|
603
|
+
if (reconcilePending) scheduleReconcileHint()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function sessionIdsFromRecords(records) {
|
|
607
|
+
const ids = new Set()
|
|
608
|
+
for (const record of records) {
|
|
609
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
610
|
+
const sid = record.header.id
|
|
611
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
612
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
613
|
+
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
614
|
+
}
|
|
615
|
+
return ids
|
|
616
|
+
}
|
|
617
|
+
async function reconcileSessions() {
|
|
618
|
+
if (disposed || reconcileInFlight || !scan.done) return
|
|
619
|
+
reconcilePending = false
|
|
620
|
+
reconcileInFlight = true
|
|
621
|
+
try {
|
|
622
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
623
|
+
if (disposed || !Array.isArray(records)) return
|
|
624
|
+
const currentIds = sessionIdsFromRecords(records)
|
|
625
|
+
let removed = false
|
|
626
|
+
for (const sid of knownSessionIds) {
|
|
627
|
+
if (!currentIds.has(sid)) { removed = true; break }
|
|
628
|
+
}
|
|
629
|
+
if (removed && !disposed && scan.done) {
|
|
630
|
+
console.info('[all-usage] session removal detected; rebuilding usage index')
|
|
631
|
+
const generation = resetAggregationState()
|
|
632
|
+
void runBaseline(generation)
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
knownSessionIds.clear()
|
|
636
|
+
for (const sid of currentIds) knownSessionIds.add(sid)
|
|
637
|
+
} catch (err) {
|
|
638
|
+
console.error('[all-usage] session reconciliation failed:', err)
|
|
639
|
+
} finally {
|
|
640
|
+
reconcileInFlight = false
|
|
641
|
+
if (reconcilePending && !disposed) scheduleReconcileHint()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function scheduleReconcileHint() {
|
|
645
|
+
if (disposed) return
|
|
646
|
+
reconcilePending = true
|
|
647
|
+
if (reconcileHintScheduled || reconcileInFlight) return
|
|
648
|
+
reconcileHintScheduled = true
|
|
649
|
+
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
650
|
+
reconcileHintScheduled = false
|
|
651
|
+
if (ready && !disposed) void reconcileSessions()
|
|
652
|
+
}, () => {
|
|
653
|
+
reconcileHintScheduled = false
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
function scheduleReconcileTimer() {
|
|
657
|
+
if (disposed || reconcileTimer !== null) return
|
|
658
|
+
reconcileTimer = setTimeout(() => {
|
|
659
|
+
reconcileTimer = null
|
|
660
|
+
if (!disposed) {
|
|
661
|
+
void reconcileSessions()
|
|
662
|
+
scheduleReconcileTimer()
|
|
663
|
+
}
|
|
664
|
+
}, RECONCILE_INTERVAL_MS)
|
|
665
|
+
if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------- live feed ----------
|
|
669
|
+
ctx.on('session/event', (session, event) => {
|
|
670
|
+
if (disposed) return
|
|
671
|
+
if (event === undefined || event === null) return
|
|
672
|
+
const type = event.type
|
|
673
|
+
if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
|
|
674
|
+
const sid = session && session.id
|
|
675
|
+
if (typeof sid !== 'string') return
|
|
676
|
+
const wsId = wsForLiveSession(session, sid)
|
|
677
|
+
if (wsId === undefined) return
|
|
678
|
+
const generation = aggregationGeneration
|
|
679
|
+
knownSessionIds.add(sid)
|
|
680
|
+
enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
|
|
681
|
+
})
|
|
682
|
+
ctx.on('session/flush', async (session) => {
|
|
683
|
+
if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
684
|
+
await ledgerReady
|
|
685
|
+
if (disposed) return
|
|
686
|
+
const wsId = wsForLiveSession(session, session.id)
|
|
687
|
+
if (wsId === undefined) return
|
|
688
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush')
|
|
689
|
+
if (ledger === null) return
|
|
690
|
+
const canonical = storeLedgerRecord(ledger)
|
|
691
|
+
if (canonical === ledger) await persistLedgerRecord(ledger)
|
|
692
|
+
})
|
|
693
|
+
ctx.on('session/disposed', () => {
|
|
694
|
+
if (!disposed) scheduleReconcileHint()
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// ---------- workspace aliases (durable, schema-free KV unit) ----------
|
|
698
|
+
async function loadAliases() {
|
|
699
|
+
if (storage === undefined) return
|
|
700
|
+
try {
|
|
701
|
+
const backend = storage.backend.get('json')
|
|
702
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
703
|
+
const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
|
|
704
|
+
kvUnit = unit
|
|
705
|
+
const snap = await unit.loadAll()
|
|
706
|
+
const g = snap && snap.global
|
|
707
|
+
if (g !== null && g !== undefined && typeof g === 'object') {
|
|
708
|
+
for (const key of Object.keys(g)) {
|
|
709
|
+
const value = g[key]
|
|
710
|
+
if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
console.error('[all-usage] alias storage unavailable:', err)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function persistAliases() {
|
|
718
|
+
const snapshotAliases = {}
|
|
719
|
+
for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
|
|
720
|
+
aliasWriteChain = aliasWriteChain.then(() => {
|
|
721
|
+
if (kvUnit === null || kvUnit === undefined) return undefined
|
|
722
|
+
return kvUnit.setGlobal(snapshotAliases).catch((err) => {
|
|
723
|
+
console.error('[all-usage] alias persist failed:', err)
|
|
724
|
+
})
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
function setAlias(wsId, raw) {
|
|
728
|
+
const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
|
|
729
|
+
if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
|
|
730
|
+
if (alias === '') delete aliases[wsId]
|
|
731
|
+
else aliases[wsId] = alias
|
|
732
|
+
persistAliases()
|
|
733
|
+
return { ok: true, aliases: Object.assign({}, aliases) }
|
|
734
|
+
}
|
|
735
|
+
ctx.effect(() => () => {
|
|
736
|
+
disposed = true
|
|
737
|
+
if (reconcileTimer !== null) {
|
|
738
|
+
clearTimeout(reconcileTimer)
|
|
739
|
+
reconcileTimer = null
|
|
740
|
+
}
|
|
741
|
+
if (baselineFallbackTimer !== null) {
|
|
742
|
+
clearTimeout(baselineFallbackTimer)
|
|
743
|
+
baselineFallbackTimer = null
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
ctx.effect(() => () => {
|
|
747
|
+
const unit = kvUnit
|
|
748
|
+
kvUnit = null
|
|
749
|
+
if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
|
|
750
|
+
})
|
|
751
|
+
ctx.effect(() => async () => {
|
|
752
|
+
const unit = ledgerUnit
|
|
753
|
+
await ledgerWriteChain
|
|
754
|
+
if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
|
|
755
|
+
if (ledgerUnit === unit) ledgerUnit = null
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
// ---------- snapshot for the client ----------
|
|
759
|
+
function serializeDays(dayMap) {
|
|
760
|
+
const result = []
|
|
761
|
+
for (const pair of dayMap) {
|
|
762
|
+
const date = pair[0]
|
|
763
|
+
const day = pair[1]
|
|
764
|
+
result.push({
|
|
765
|
+
date,
|
|
766
|
+
turns: day.turns,
|
|
767
|
+
sessions: day.sessionIds.size,
|
|
768
|
+
sessionIds: Array.from(day.sessionIds).sort(),
|
|
769
|
+
tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
|
|
770
|
+
perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
|
|
771
|
+
byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
772
|
+
byModel: Array.from(day.byModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
|
|
776
|
+
return result
|
|
777
|
+
}
|
|
778
|
+
function snapshot() {
|
|
779
|
+
return {
|
|
780
|
+
scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
|
|
781
|
+
generatedAt: Date.now(),
|
|
782
|
+
requestToken,
|
|
783
|
+
workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
|
|
784
|
+
aliases: Object.assign({}, aliases),
|
|
785
|
+
tokenSemantics: {
|
|
786
|
+
processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
|
|
787
|
+
cacheRead: 'reused context tokens; not newly generated output',
|
|
788
|
+
cacheWrite: 'tokens written into a provider cache',
|
|
789
|
+
},
|
|
790
|
+
totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
|
|
791
|
+
perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
792
|
+
perModel: Array.from(perModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
|
|
793
|
+
byDay: serializeDays(byDay),
|
|
794
|
+
byDayUtc: serializeDays(byDayUtc),
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------- account balance (DeepSeek open platform) ----------
|
|
799
|
+
async function requestBalance(url, key) {
|
|
800
|
+
let controller = null
|
|
801
|
+
let timer = null
|
|
802
|
+
try {
|
|
803
|
+
if (typeof AbortController === 'function') {
|
|
804
|
+
controller = new AbortController()
|
|
805
|
+
timer = setTimeout(() => controller.abort(), 30000)
|
|
806
|
+
}
|
|
807
|
+
const response = await fetch(url, {
|
|
808
|
+
method: 'GET',
|
|
809
|
+
headers: { accept: 'application/json', authorization: 'Bearer ' + key },
|
|
810
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
811
|
+
})
|
|
812
|
+
return { ok: response.ok, status: response.status, text: await response.text() }
|
|
813
|
+
} catch (err) {
|
|
814
|
+
return { ok: false, status: 0, text: '', error: 'network request failed' }
|
|
815
|
+
} finally {
|
|
816
|
+
if (timer !== null) clearTimeout(timer)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function moneyOf(v) {
|
|
820
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
821
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
822
|
+
const n = parseFloat(v)
|
|
823
|
+
if (Number.isFinite(n)) return n
|
|
824
|
+
}
|
|
825
|
+
return null
|
|
826
|
+
}
|
|
827
|
+
function parseBalance(text) {
|
|
828
|
+
let obj = null
|
|
829
|
+
try {
|
|
830
|
+
obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
|
|
831
|
+
} catch (err) {
|
|
832
|
+
return null
|
|
833
|
+
}
|
|
834
|
+
if (obj === null || typeof obj !== 'object') return null
|
|
835
|
+
if (obj.is_available === false) return { unavailable: true, currencies: [] }
|
|
836
|
+
const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
|
|
837
|
+
if (!infos) return null
|
|
838
|
+
const out = []
|
|
839
|
+
for (const info of infos) {
|
|
840
|
+
if (info === null || typeof info !== 'object') continue
|
|
841
|
+
if (typeof info.currency !== 'string') continue
|
|
842
|
+
out.push({
|
|
843
|
+
currency: info.currency,
|
|
844
|
+
total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
|
|
845
|
+
granted: moneyOf(info.granted_balance),
|
|
846
|
+
toppedUp: moneyOf(info.topped_up_balance),
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
return { unavailable: false, currencies: out }
|
|
850
|
+
}
|
|
851
|
+
async function fetchBalance(force) {
|
|
852
|
+
const now = Date.now()
|
|
853
|
+
if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
|
|
854
|
+
let ref = 'DEEPSEEK_API_KEY'
|
|
855
|
+
if (settings !== undefined) {
|
|
856
|
+
try {
|
|
857
|
+
const section = settings.get('llm-deepseek')
|
|
858
|
+
if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
|
|
859
|
+
} catch (err) { /* default ref */ }
|
|
860
|
+
}
|
|
861
|
+
let key
|
|
862
|
+
if (credentials !== undefined) {
|
|
863
|
+
try {
|
|
864
|
+
const hit = await credentials.resolve(ref)
|
|
865
|
+
if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
|
|
866
|
+
} catch (err) { /* unconfigured */ }
|
|
867
|
+
}
|
|
868
|
+
if (key === undefined) {
|
|
869
|
+
const payload = { status: 'missing-key' }
|
|
870
|
+
balanceCache = { fetchedAt: now, payload }
|
|
871
|
+
return payload
|
|
872
|
+
}
|
|
873
|
+
if (typeof fetch !== 'function') {
|
|
874
|
+
const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
|
|
875
|
+
balanceCache = { fetchedAt: now, payload }
|
|
876
|
+
return payload
|
|
877
|
+
}
|
|
878
|
+
const result = await requestBalance('https://api.deepseek.com/user/balance', key)
|
|
879
|
+
const body = (result.text || '').trim()
|
|
880
|
+
if (result.ok && body.length > 0) {
|
|
881
|
+
const parsed = parseBalance(body)
|
|
882
|
+
if (parsed !== null && parsed.unavailable) {
|
|
883
|
+
const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
|
|
884
|
+
balanceCache = { fetchedAt: now, payload }
|
|
885
|
+
return payload
|
|
886
|
+
}
|
|
887
|
+
if (parsed !== null && parsed.currencies.length > 0) {
|
|
888
|
+
const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
|
|
889
|
+
balanceCache = { fetchedAt: now, payload }
|
|
890
|
+
return payload
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
|
|
894
|
+
const payload = { status: 'error', message: '余额查询失败', detail }
|
|
895
|
+
balanceCache = { fetchedAt: now, payload }
|
|
896
|
+
return payload
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// ---------- HTTP data routes for the client half ----------
|
|
900
|
+
if (webServer !== undefined) {
|
|
901
|
+
const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
|
|
902
|
+
ctx.effect(() => webServer.register({
|
|
903
|
+
kind: 'exact',
|
|
904
|
+
path: '/api/all-usage',
|
|
905
|
+
handler: (req, res) => {
|
|
906
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
907
|
+
if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
|
|
908
|
+
if (!scan.started) void runBaseline()
|
|
909
|
+
sendJson(res, 200, snapshot())
|
|
910
|
+
},
|
|
911
|
+
}))
|
|
912
|
+
ctx.effect(() => webServer.register({
|
|
913
|
+
kind: 'exact',
|
|
914
|
+
path: '/api/all-usage/balance',
|
|
915
|
+
handler: async (req, res) => {
|
|
916
|
+
if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
|
|
917
|
+
// Browsers may omit Origin on same-origin GET; the process token remains required.
|
|
918
|
+
if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
919
|
+
let force = false
|
|
920
|
+
try {
|
|
921
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
922
|
+
force = url.searchParams.get('force') === '1'
|
|
923
|
+
} catch (err) { /* default */ }
|
|
924
|
+
sendJson(res, 200, await fetchBalance(force))
|
|
925
|
+
},
|
|
926
|
+
}))
|
|
927
|
+
ctx.effect(() => webServer.register({
|
|
928
|
+
kind: 'exact',
|
|
929
|
+
path: '/api/all-usage/alias',
|
|
930
|
+
handler: async (req, res) => {
|
|
931
|
+
if (req.method !== 'POST') {
|
|
932
|
+
res.statusCode = 405
|
|
933
|
+
res.end()
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
|
|
937
|
+
const body = await readBody(req, 16 * 1024)
|
|
938
|
+
let args = null
|
|
939
|
+
try {
|
|
940
|
+
args = JSON.parse(body)
|
|
941
|
+
} catch (err) { /* invalid json */ }
|
|
942
|
+
const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
|
|
943
|
+
? setAlias(args.workspaceId, args.alias)
|
|
944
|
+
: { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
|
|
945
|
+
sendJson(res, 200, result)
|
|
946
|
+
},
|
|
947
|
+
}))
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---------- start the historical backfill immediately ----------
|
|
951
|
+
ledgerReady = loadLedger()
|
|
952
|
+
void runBaseline()
|
|
953
|
+
scheduleReconcileTimer()
|
|
954
|
+
void loadAliases()
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export { name, inject, apply }
|
|
958
|
+
export default { name, inject, apply }
|