@goodandready/dsh-clinebot 0.5.33 → 0.6.1
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/CHANGELOG.md +32 -0
- package/README.md +63 -36
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/adapter-messages.js +237 -0
- package/lib/adapter-stream.js +292 -0
- package/lib/adapter.js +616 -0
- package/lib/client.js +2634 -922
- package/lib/cline-client.js +15 -6
- package/lib/config.js +266 -233
- package/lib/credential-refs.js +104 -84
- package/lib/index.js +569 -502
- package/lib/model-prices.js +117 -0
- package/lib/provider-sync.js +404 -387
- package/lib/proxy-token.js +186 -186
- package/lib/routes/accounts.js +523 -403
- package/lib/routes/models.js +2 -1
- package/lib/routes/settings.js +81 -2
- package/lib/services.js +55 -0
- package/lib/stats-storage.js +378 -165
- package/lib/transport/cline-transport.js +314 -0
- package/lib/tui-settings.js +158 -0
- package/package.json +113 -77
package/lib/stats-storage.js
CHANGED
|
@@ -1,51 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Persistent JSON
|
|
2
|
+
* Persistent JSON telemetry storage.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Version 2 adds cache buckets, estimated cost, TTFT/latency sums and a bounded
|
|
5
|
+
* per-session dimension. Version 1 files are upgraded on read.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
12
|
+
const MAX_SESSIONS = 500
|
|
13
|
+
const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
|
|
14
|
+
const HEATMAP_RETENTION_MS = 95 * 24 * 60 * 60 * 1000
|
|
15
|
+
|
|
12
16
|
let inMemoryStats = createEmptyStats()
|
|
13
17
|
let saveTimer = null
|
|
14
18
|
let activeStatsPath = ''
|
|
15
19
|
|
|
20
|
+
function emptyBucket() {
|
|
21
|
+
return {
|
|
22
|
+
requests: 0,
|
|
23
|
+
successful: 0,
|
|
24
|
+
failed: 0,
|
|
25
|
+
rateLimited429: 0,
|
|
26
|
+
promptTokens: 0,
|
|
27
|
+
completionTokens: 0,
|
|
28
|
+
reasoningTokens: 0,
|
|
29
|
+
cacheReadTokens: 0,
|
|
30
|
+
cacheWriteTokens: 0,
|
|
31
|
+
totalTokens: 0,
|
|
32
|
+
costUsd: 0,
|
|
33
|
+
pricedRequests: 0,
|
|
34
|
+
unpricedRequests: 0,
|
|
35
|
+
ttftMsSum: 0,
|
|
36
|
+
latencyMsSum: 0,
|
|
37
|
+
firstAt: 0,
|
|
38
|
+
lastAt: 0,
|
|
39
|
+
model: '',
|
|
40
|
+
accountId: '',
|
|
41
|
+
purpose: '',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
/**
|
|
17
46
|
* Create a fresh, empty stats object.
|
|
18
47
|
*/
|
|
19
48
|
export function createEmptyStats() {
|
|
20
49
|
return {
|
|
21
|
-
version:
|
|
50
|
+
version: 2,
|
|
22
51
|
createdAt: Date.now(),
|
|
23
52
|
updatedAt: Date.now(),
|
|
24
|
-
totals:
|
|
25
|
-
requests: 0,
|
|
26
|
-
successful: 0,
|
|
27
|
-
failed: 0,
|
|
28
|
-
rateLimited429: 0,
|
|
29
|
-
promptTokens: 0,
|
|
30
|
-
completionTokens: 0,
|
|
31
|
-
reasoningTokens: 0,
|
|
32
|
-
totalTokens: 0
|
|
33
|
-
},
|
|
53
|
+
totals: emptyBucket(),
|
|
34
54
|
byDay: {},
|
|
35
55
|
byMonth: {},
|
|
36
56
|
byModel: {},
|
|
37
|
-
byAccount: {}
|
|
57
|
+
byAccount: {},
|
|
58
|
+
bySession: {},
|
|
59
|
+
byHour: {},
|
|
60
|
+
byHourModel: {},
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* @returns {string}
|
|
48
|
-
*/
|
|
64
|
+
/** Local `YYYY-MM-DD` key, used by the request heatmap. */
|
|
65
|
+
function localDateKey(date) {
|
|
66
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get active .dsh directory, respecting DSH_HOME and HOME. */
|
|
49
70
|
export function getDshHome() {
|
|
50
71
|
if (process.env.DSH_HOME) return process.env.DSH_HOME
|
|
51
72
|
const home = process.env.HOME || homedir()
|
|
@@ -65,12 +86,8 @@ export function resolvePath(targetPath) {
|
|
|
65
86
|
break
|
|
66
87
|
}
|
|
67
88
|
}
|
|
68
|
-
if (typeof targetPath !== 'string')
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
if (!targetPath) {
|
|
72
|
-
return path.join(getDshHome(), 'clinebot-stats.json')
|
|
73
|
-
}
|
|
89
|
+
if (typeof targetPath !== 'string') targetPath = ''
|
|
90
|
+
if (!targetPath) return path.join(getDshHome(), 'clinebot-stats.json')
|
|
74
91
|
if (targetPath.startsWith('~/') || targetPath === '~') {
|
|
75
92
|
const home = process.env.HOME || homedir()
|
|
76
93
|
return path.join(home, targetPath.slice(1))
|
|
@@ -80,9 +97,7 @@ export function resolvePath(targetPath) {
|
|
|
80
97
|
|
|
81
98
|
/**
|
|
82
99
|
* Load stats from disk.
|
|
83
|
-
*
|
|
84
100
|
* @param {string} [customPath]
|
|
85
|
-
* @returns {Object} Loaded stats
|
|
86
101
|
*/
|
|
87
102
|
export function loadStats(customPath) {
|
|
88
103
|
const filePath = resolvePath(customPath)
|
|
@@ -92,20 +107,25 @@ export function loadStats(customPath) {
|
|
|
92
107
|
const raw = readFileSync(filePath, 'utf8')
|
|
93
108
|
const parsed = JSON.parse(raw)
|
|
94
109
|
if (parsed && typeof parsed === 'object' && parsed.totals) {
|
|
110
|
+
const empty = createEmptyStats()
|
|
95
111
|
inMemoryStats = {
|
|
96
|
-
...
|
|
112
|
+
...empty,
|
|
97
113
|
...parsed,
|
|
98
|
-
|
|
114
|
+
version: 2,
|
|
115
|
+
totals: { ...empty.totals, ...parsed.totals },
|
|
99
116
|
byDay: parsed.byDay || {},
|
|
100
117
|
byMonth: parsed.byMonth || {},
|
|
101
118
|
byModel: parsed.byModel || {},
|
|
102
|
-
byAccount: parsed.byAccount || {}
|
|
119
|
+
byAccount: parsed.byAccount || {},
|
|
120
|
+
bySession: parsed.bySession || {},
|
|
121
|
+
byHour: parsed.byHour || {},
|
|
122
|
+
byHourModel: parsed.byHourModel || {},
|
|
103
123
|
}
|
|
124
|
+
backfillHourlyFromSessions()
|
|
104
125
|
return inMemoryStats
|
|
105
126
|
}
|
|
106
127
|
}
|
|
107
|
-
} catch
|
|
108
|
-
// If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
|
|
128
|
+
} catch {
|
|
109
129
|
try {
|
|
110
130
|
if (existsSync(filePath)) {
|
|
111
131
|
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
@@ -113,52 +133,29 @@ export function loadStats(customPath) {
|
|
|
113
133
|
const dir = path.dirname(filePath)
|
|
114
134
|
const base = path.basename(filePath)
|
|
115
135
|
const prefix = `${base}.corrupt.`
|
|
116
|
-
const existing = readdirSync(dir)
|
|
117
|
-
.filter((name) => name.startsWith(prefix))
|
|
118
|
-
.sort()
|
|
136
|
+
const existing = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort()
|
|
119
137
|
while (existing.length > 3) {
|
|
120
138
|
const oldest = existing.shift()
|
|
121
|
-
try {
|
|
122
|
-
unlinkSync(path.join(dir, oldest))
|
|
123
|
-
} catch {
|
|
124
|
-
/* ignore deletion failure */
|
|
125
|
-
}
|
|
139
|
+
try { unlinkSync(path.join(dir, oldest)) } catch {}
|
|
126
140
|
}
|
|
127
141
|
}
|
|
128
|
-
} catch {
|
|
129
|
-
/* ignore backup failure */
|
|
130
|
-
}
|
|
142
|
+
} catch {}
|
|
131
143
|
}
|
|
132
|
-
|
|
133
144
|
inMemoryStats = createEmptyStats()
|
|
134
145
|
return inMemoryStats
|
|
135
146
|
}
|
|
136
147
|
|
|
137
|
-
/**
|
|
138
|
-
* Switch active stats storage to a new path.
|
|
139
|
-
* Flushes pending writes of current storage and loads stats from the new path.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} newPath
|
|
142
|
-
* @returns {string} active stats path
|
|
143
|
-
*/
|
|
148
|
+
/** Switch active stats storage and load the target file. */
|
|
144
149
|
export function switchStatsStorage(newPath) {
|
|
145
150
|
const resolved = resolvePath(newPath)
|
|
146
151
|
if (resolved && resolved !== activeStatsPath) {
|
|
147
|
-
if (activeStatsPath)
|
|
148
|
-
flushStats(activeStatsPath)
|
|
149
|
-
}
|
|
152
|
+
if (activeStatsPath) flushStats(activeStatsPath)
|
|
150
153
|
activeStatsPath = resolved
|
|
151
154
|
loadStats(resolved)
|
|
152
155
|
}
|
|
153
156
|
return activeStatsPath
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Flush any debounced pending writes immediately to disk.
|
|
159
|
-
*
|
|
160
|
-
* @param {string} [customPath]
|
|
161
|
-
*/
|
|
162
159
|
export function flushStats(customPath) {
|
|
163
160
|
if (saveTimer) {
|
|
164
161
|
clearTimeout(saveTimer)
|
|
@@ -167,167 +164,383 @@ export function flushStats(customPath) {
|
|
|
167
164
|
saveStatsSync(customPath)
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
/**
|
|
171
|
-
* Synchronously write stats to disk using atomic rename.
|
|
172
|
-
*
|
|
173
|
-
* @param {string} [customPath]
|
|
174
|
-
*/
|
|
175
167
|
export function saveStatsSync(customPath) {
|
|
176
168
|
try {
|
|
177
169
|
const filePath = resolvePath(customPath || activeStatsPath)
|
|
178
170
|
activeStatsPath = filePath
|
|
179
|
-
|
|
180
171
|
const dir = path.dirname(filePath)
|
|
181
|
-
if (!existsSync(dir)) {
|
|
182
|
-
mkdirSync(dir, { recursive: true })
|
|
183
|
-
}
|
|
184
|
-
|
|
172
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
185
173
|
inMemoryStats.updatedAt = Date.now()
|
|
186
174
|
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
writeFileSync(tempPath, data, 'utf8')
|
|
175
|
+
writeFileSync(tempPath, JSON.stringify(inMemoryStats, null, 2), 'utf8')
|
|
190
176
|
renameSync(tempPath, filePath)
|
|
191
177
|
} catch {
|
|
192
|
-
|
|
178
|
+
/* best-effort disk persistence */
|
|
193
179
|
}
|
|
194
180
|
}
|
|
195
181
|
|
|
196
|
-
/**
|
|
197
|
-
* Queue a debounced write to avoid disk thrashing during rapid streaming tokens.
|
|
198
|
-
*
|
|
199
|
-
* @param {string} [customPath]
|
|
200
|
-
* @param {number} [debounceMs=500]
|
|
201
|
-
*/
|
|
202
182
|
function queueSaveStats(customPath, debounceMs = 500) {
|
|
203
|
-
if (saveTimer)
|
|
204
|
-
clearTimeout(saveTimer)
|
|
205
|
-
}
|
|
183
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
206
184
|
saveTimer = setTimeout(() => {
|
|
207
185
|
saveTimer = null
|
|
208
|
-
try {
|
|
209
|
-
saveStatsSync(customPath)
|
|
210
|
-
} catch {
|
|
211
|
-
// Best-effort disk persistence, never crash host process
|
|
212
|
-
}
|
|
186
|
+
try { saveStatsSync(customPath) } catch {}
|
|
213
187
|
}, debounceMs)
|
|
214
188
|
}
|
|
215
189
|
|
|
190
|
+
function addToBucket(bucket, entry) {
|
|
191
|
+
bucket.requests += 1
|
|
192
|
+
if (entry.isError) bucket.failed += 1
|
|
193
|
+
else bucket.successful += 1
|
|
194
|
+
if (entry.is429) bucket.rateLimited429 += 1
|
|
195
|
+
bucket.promptTokens += entry.promptTokens
|
|
196
|
+
bucket.completionTokens += entry.completionTokens
|
|
197
|
+
bucket.reasoningTokens += entry.reasoningTokens
|
|
198
|
+
bucket.cacheReadTokens += entry.cacheReadTokens
|
|
199
|
+
bucket.cacheWriteTokens += entry.cacheWriteTokens
|
|
200
|
+
bucket.totalTokens += entry.totalTokens
|
|
201
|
+
bucket.costUsd += entry.costUsd
|
|
202
|
+
if (entry.priced) bucket.pricedRequests += 1
|
|
203
|
+
else bucket.unpricedRequests += 1
|
|
204
|
+
bucket.ttftMsSum += entry.ttftMs
|
|
205
|
+
bucket.latencyMsSum += entry.latencyMs
|
|
206
|
+
bucket.firstAt = bucket.firstAt || entry.now
|
|
207
|
+
bucket.lastAt = entry.now
|
|
208
|
+
if (entry.model) bucket.model = entry.model
|
|
209
|
+
if (entry.accountId) bucket.accountId = entry.accountId
|
|
210
|
+
if (entry.purpose) bucket.purpose = entry.purpose
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function incrementBucket(container, key, entry) {
|
|
214
|
+
if (!container[key]) container[key] = emptyBucket()
|
|
215
|
+
addToBucket(container[key], entry)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pruneSessions(now = Date.now()) {
|
|
219
|
+
const sessions = inMemoryStats.bySession
|
|
220
|
+
for (const [id, bucket] of Object.entries(sessions)) {
|
|
221
|
+
if (now - Number(bucket.lastAt || 0) > SESSION_TTL_MS) delete sessions[id]
|
|
222
|
+
}
|
|
223
|
+
const ids = Object.keys(sessions)
|
|
224
|
+
if (ids.length <= MAX_SESSIONS) return
|
|
225
|
+
ids.sort((a, b) => Number(sessions[a].lastAt || 0) - Number(sessions[b].lastAt || 0))
|
|
226
|
+
for (const id of ids.slice(0, ids.length - MAX_SESSIONS)) delete sessions[id]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pruneHours(now = Date.now()) {
|
|
230
|
+
const cutoff = localDateKey(new Date(now - HEATMAP_RETENTION_MS))
|
|
231
|
+
for (const container of [inMemoryStats.byHour, inMemoryStats.byHourModel]) {
|
|
232
|
+
for (const key of Object.keys(container || {})) {
|
|
233
|
+
if (key < cutoff) delete container[key]
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
216
238
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
* Best-effort first-run backfill: older files predate the hourly buckets, so
|
|
240
|
+
* each persisted session contributes its requests to the hour it last ran.
|
|
241
|
+
* This is approximate by design; it only runs while the hourly map is empty.
|
|
242
|
+
*/
|
|
243
|
+
function backfillHourlyFromSessions() {
|
|
244
|
+
if (Object.keys(inMemoryStats.byHour || {}).length > 0) return
|
|
245
|
+
for (const bucket of Object.values(inMemoryStats.bySession || {})) {
|
|
246
|
+
const requests = Number(bucket?.requests || 0)
|
|
247
|
+
if (requests <= 0) continue
|
|
248
|
+
const at = Number(bucket.lastAt || bucket.firstAt || 0)
|
|
249
|
+
if (!Number.isFinite(at) || at <= 0) continue
|
|
250
|
+
const local = new Date(at)
|
|
251
|
+
const key = `${localDateKey(local)}T${String(local.getHours()).padStart(2, '0')}`
|
|
252
|
+
if (!inMemoryStats.byHour[key]) inMemoryStats.byHour[key] = emptyBucket()
|
|
253
|
+
const hour = inMemoryStats.byHour[key]
|
|
254
|
+
hour.requests += requests
|
|
255
|
+
hour.successful += Number(bucket.successful || 0)
|
|
256
|
+
hour.failed += Number(bucket.failed || 0)
|
|
257
|
+
hour.rateLimited429 += Number(bucket.rateLimited429 || 0)
|
|
258
|
+
hour.promptTokens += Number(bucket.promptTokens || 0)
|
|
259
|
+
hour.completionTokens += Number(bucket.completionTokens || 0)
|
|
260
|
+
hour.reasoningTokens += Number(bucket.reasoningTokens || 0)
|
|
261
|
+
hour.cacheReadTokens += Number(bucket.cacheReadTokens || 0)
|
|
262
|
+
hour.cacheWriteTokens += Number(bucket.cacheWriteTokens || 0)
|
|
263
|
+
hour.totalTokens += Number(bucket.totalTokens || 0)
|
|
264
|
+
hour.costUsd += Number(bucket.costUsd || 0)
|
|
265
|
+
hour.pricedRequests += Number(bucket.pricedRequests || 0)
|
|
266
|
+
hour.unpricedRequests += Number(bucket.unpricedRequests || 0)
|
|
267
|
+
hour.ttftMsSum += Number(bucket.ttftMsSum || 0)
|
|
268
|
+
hour.latencyMsSum += Number(bucket.latencyMsSum || 0)
|
|
269
|
+
hour.firstAt = hour.firstAt ? Math.min(hour.firstAt, bucket.firstAt || at) : (bucket.firstAt || at)
|
|
270
|
+
hour.lastAt = Math.max(hour.lastAt, at)
|
|
271
|
+
if (bucket.model) hour.model = bucket.model
|
|
272
|
+
if (bucket.accountId) hour.accountId = bucket.accountId
|
|
273
|
+
const model = bucket.model || 'unknown'
|
|
274
|
+
if (!inMemoryStats.byHourModel[key]) inMemoryStats.byHourModel[key] = {}
|
|
275
|
+
inMemoryStats.byHourModel[key][model] = Number(inMemoryStats.byHourModel[key][model] || 0) + requests
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Record one completion event.
|
|
281
|
+
* @param {object} entry
|
|
228
282
|
*/
|
|
229
283
|
export function recordUsage({
|
|
230
284
|
model = 'unknown',
|
|
231
285
|
accountId = 'default',
|
|
286
|
+
sessionId,
|
|
287
|
+
purpose,
|
|
232
288
|
promptTokens = 0,
|
|
233
289
|
completionTokens = 0,
|
|
234
290
|
reasoningTokens = 0,
|
|
291
|
+
cacheReadTokens = 0,
|
|
292
|
+
cacheWriteTokens = 0,
|
|
235
293
|
totalTokens = 0,
|
|
294
|
+
costUsd = 0,
|
|
295
|
+
priced = false,
|
|
296
|
+
ttftMs = 0,
|
|
297
|
+
latencyMs = 0,
|
|
236
298
|
isError = false,
|
|
237
299
|
is429 = false,
|
|
238
|
-
statsPath
|
|
300
|
+
statsPath,
|
|
239
301
|
} = {}) {
|
|
240
|
-
if (statsPath)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
|
|
245
|
-
const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
|
|
246
|
-
|
|
302
|
+
if (statsPath) switchStatsStorage(statsPath)
|
|
303
|
+
const now = Date.now()
|
|
304
|
+
const dayKey = new Date(now).toISOString().slice(0, 10)
|
|
305
|
+
const monthKey = dayKey.slice(0, 7)
|
|
247
306
|
const safePrompt = Math.max(0, Number(promptTokens) || 0)
|
|
248
307
|
const safeComp = Math.max(0, Number(completionTokens) || 0)
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// Helper to increment bucket
|
|
268
|
-
function incrementBucket(container, key) {
|
|
269
|
-
if (!container[key]) {
|
|
270
|
-
container[key] = {
|
|
271
|
-
requests: 0,
|
|
272
|
-
promptTokens: 0,
|
|
273
|
-
completionTokens: 0,
|
|
274
|
-
reasoningTokens: 0,
|
|
275
|
-
totalTokens: 0
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
container[key].requests += 1
|
|
279
|
-
container[key].promptTokens += safePrompt
|
|
280
|
-
container[key].completionTokens += safeComp
|
|
281
|
-
container[key].reasoningTokens = (container[key].reasoningTokens || 0) + safeReasoning
|
|
282
|
-
container[key].totalTokens += safeTotal
|
|
308
|
+
const entry = {
|
|
309
|
+
now,
|
|
310
|
+
model,
|
|
311
|
+
accountId,
|
|
312
|
+
purpose: purpose || '',
|
|
313
|
+
promptTokens: safePrompt,
|
|
314
|
+
completionTokens: safeComp,
|
|
315
|
+
reasoningTokens: Math.max(0, Number(reasoningTokens) || 0),
|
|
316
|
+
cacheReadTokens: Math.max(0, Number(cacheReadTokens) || 0),
|
|
317
|
+
cacheWriteTokens: Math.max(0, Number(cacheWriteTokens) || 0),
|
|
318
|
+
totalTokens: Math.max(safePrompt + safeComp, Number(totalTokens) || 0),
|
|
319
|
+
costUsd: Math.max(0, Number(costUsd) || 0),
|
|
320
|
+
priced: Boolean(priced),
|
|
321
|
+
ttftMs: Math.max(0, Number(ttftMs) || 0),
|
|
322
|
+
latencyMs: Math.max(0, Number(latencyMs) || 0),
|
|
323
|
+
isError: Boolean(isError),
|
|
324
|
+
is429: Boolean(is429),
|
|
283
325
|
}
|
|
284
326
|
|
|
285
|
-
|
|
286
|
-
incrementBucket(inMemoryStats.
|
|
287
|
-
incrementBucket(inMemoryStats.
|
|
288
|
-
incrementBucket(inMemoryStats.
|
|
289
|
-
|
|
327
|
+
addToBucket(inMemoryStats.totals, entry)
|
|
328
|
+
incrementBucket(inMemoryStats.byDay, dayKey, entry)
|
|
329
|
+
incrementBucket(inMemoryStats.byMonth, monthKey, entry)
|
|
330
|
+
incrementBucket(inMemoryStats.byModel, model, entry)
|
|
331
|
+
incrementBucket(inMemoryStats.byAccount, accountId, entry)
|
|
332
|
+
const localNow = new Date(now)
|
|
333
|
+
const hourKey = `${localDateKey(localNow)}T${String(localNow.getHours()).padStart(2, '0')}`
|
|
334
|
+
incrementBucket(inMemoryStats.byHour, hourKey, entry)
|
|
335
|
+
if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
|
|
336
|
+
inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
|
|
337
|
+
pruneHours(now)
|
|
338
|
+
if (sessionId) {
|
|
339
|
+
const key = String(sessionId)
|
|
340
|
+
incrementBucket(inMemoryStats.bySession, key, entry)
|
|
341
|
+
const bucket = inMemoryStats.bySession[key]
|
|
342
|
+
if (bucket) bucket.last = { ...entry, sessionId: key }
|
|
343
|
+
pruneSessions(now)
|
|
344
|
+
}
|
|
290
345
|
queueSaveStats(statsPath)
|
|
291
346
|
}
|
|
292
347
|
|
|
293
|
-
/**
|
|
294
|
-
* Retrieve current statistics summary.
|
|
295
|
-
*
|
|
296
|
-
* @returns {Object}
|
|
297
|
-
*/
|
|
298
348
|
export function getStatsSummary() {
|
|
299
349
|
return JSON.parse(JSON.stringify(inMemoryStats))
|
|
300
350
|
}
|
|
301
351
|
|
|
302
352
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* @param {string}
|
|
353
|
+
* Return the ClineBot-only aggregate for one DSH session.
|
|
354
|
+
* Returns null when the session has no recorded ClineBot traffic.
|
|
355
|
+
* @param {string} sessionId
|
|
306
356
|
*/
|
|
357
|
+
export function getSessionStats(sessionId) {
|
|
358
|
+
const key = String(sessionId || '').trim()
|
|
359
|
+
if (!key) return null
|
|
360
|
+
const bucket = inMemoryStats.bySession?.[key]
|
|
361
|
+
return bucket ? JSON.parse(JSON.stringify(bucket)) : null
|
|
362
|
+
}
|
|
363
|
+
|
|
307
364
|
export function resetStats(customPath) {
|
|
308
365
|
inMemoryStats = createEmptyStats()
|
|
309
366
|
saveStatsSync(customPath)
|
|
310
367
|
return inMemoryStats
|
|
311
368
|
}
|
|
312
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Hourly request heatmap for the last `days` local days.
|
|
372
|
+
* @param {number} [days]
|
|
373
|
+
* @param {number} [to]
|
|
374
|
+
*/
|
|
375
|
+
export function getHeatmap(days = 7, to = Date.now(), model = '') {
|
|
376
|
+
const count = Math.max(1, Math.min(95, Number(days) || 7))
|
|
377
|
+
const end = new Date(Number.isFinite(Number(to)) ? Number(to) : Date.now())
|
|
378
|
+
const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - (count - 1))
|
|
379
|
+
const startKey = localDateKey(start)
|
|
380
|
+
const endKey = localDateKey(end)
|
|
381
|
+
const grid = Array.from({ length: 7 }, () => Array(24).fill(0))
|
|
382
|
+
const hours = Array(24).fill(0)
|
|
383
|
+
let total = 0
|
|
384
|
+
|
|
385
|
+
for (const [key, bucket] of Object.entries(inMemoryStats.byHour || {})) {
|
|
386
|
+
const datePart = key.slice(0, 10)
|
|
387
|
+
if (datePart < startKey || datePart > endKey) continue
|
|
388
|
+
const hour = Number(key.slice(11, 13))
|
|
389
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue
|
|
390
|
+
const date = new Date(`${datePart}T00:00:00`)
|
|
391
|
+
if (Number.isNaN(date.getTime())) continue
|
|
392
|
+
const weekday = (date.getDay() + 6) % 7
|
|
393
|
+
const requests = model
|
|
394
|
+
? Number(inMemoryStats.byHourModel?.[key]?.[model] || 0)
|
|
395
|
+
: Number(bucket.requests || 0)
|
|
396
|
+
if (requests <= 0) continue
|
|
397
|
+
grid[weekday][hour] += requests
|
|
398
|
+
hours[hour] += requests
|
|
399
|
+
total += requests
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const modelTotals = new Map()
|
|
403
|
+
for (const [key, perModel] of Object.entries(inMemoryStats.byHourModel || {})) {
|
|
404
|
+
const datePart = key.slice(0, 10)
|
|
405
|
+
if (datePart < startKey || datePart > endKey) continue
|
|
406
|
+
for (const [id, value] of Object.entries(perModel || {})) {
|
|
407
|
+
if (!id) continue
|
|
408
|
+
modelTotals.set(id, (modelTotals.get(id) || 0) + Number(value || 0))
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const models = [...modelTotals.entries()]
|
|
412
|
+
.filter(([, value]) => value > 0)
|
|
413
|
+
.map(([id, value]) => ({ model: id, total: value }))
|
|
414
|
+
.sort((a, b) => b.total - a.total)
|
|
415
|
+
|
|
416
|
+
return { total, grid, hours, models, days: count }
|
|
417
|
+
}
|
|
313
418
|
|
|
314
419
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
|
|
420
|
+
* Aggregate analytics for a time range.
|
|
421
|
+
* @param {{ from?: number|string, to?: number|string, groupBy?: string, limit?: number, model?: string, account?: string, heatmapDays?: number|string, heatmapModel?: string }} [query]
|
|
422
|
+
*/
|
|
423
|
+
export function getAnalytics(query = {}) {
|
|
424
|
+
const now = Date.now()
|
|
425
|
+
const from = Number.isFinite(Number(query.from)) ? Number(query.from) : 0
|
|
426
|
+
const to = Number.isFinite(Number(query.to)) ? Number(query.to) : now
|
|
427
|
+
const groupBy = ['session', 'day', 'model', 'account'].includes(query.groupBy) ? query.groupBy : 'model'
|
|
428
|
+
const limit = Math.max(1, Math.min(500, Number(query.limit) || 50))
|
|
429
|
+
const source = groupBy === 'session'
|
|
430
|
+
? inMemoryStats.bySession
|
|
431
|
+
: groupBy === 'day'
|
|
432
|
+
? inMemoryStats.byDay
|
|
433
|
+
: groupBy === 'account'
|
|
434
|
+
? inMemoryStats.byAccount
|
|
435
|
+
: inMemoryStats.byModel
|
|
436
|
+
|
|
437
|
+
const groups = []
|
|
438
|
+
for (const [key, bucket] of Object.entries(source || {})) {
|
|
439
|
+
const at = groupBy === 'day' ? Date.parse(`${key}T00:00:00Z`) : Number(bucket.lastAt || 0)
|
|
440
|
+
if (Number.isFinite(at) && (at < from || at > to)) continue
|
|
441
|
+
if (query.model && bucket.model && bucket.model !== query.model && key !== query.model) continue
|
|
442
|
+
if (query.account && bucket.accountId && bucket.accountId !== query.account && key !== query.account) continue
|
|
443
|
+
const requests = Number(bucket.requests || 0)
|
|
444
|
+
const successful = Number(bucket.successful || 0)
|
|
445
|
+
groups.push({
|
|
446
|
+
key,
|
|
447
|
+
label: key,
|
|
448
|
+
requests,
|
|
449
|
+
successful,
|
|
450
|
+
failed: Number(bucket.failed || 0),
|
|
451
|
+
rateLimited429: Number(bucket.rateLimited429 || 0),
|
|
452
|
+
promptTokens: Number(bucket.promptTokens || 0),
|
|
453
|
+
completionTokens: Number(bucket.completionTokens || 0),
|
|
454
|
+
reasoningTokens: Number(bucket.reasoningTokens || 0),
|
|
455
|
+
cacheReadTokens: Number(bucket.cacheReadTokens || 0),
|
|
456
|
+
cacheWriteTokens: Number(bucket.cacheWriteTokens || 0),
|
|
457
|
+
totalTokens: Number(bucket.totalTokens || 0),
|
|
458
|
+
costUsd: Number(bucket.costUsd || 0),
|
|
459
|
+
pricedRequests: Number(bucket.pricedRequests || 0),
|
|
460
|
+
unpricedRequests: Number(bucket.unpricedRequests || 0),
|
|
461
|
+
successRate: requests > 0 ? (successful / requests) * 100 : 0,
|
|
462
|
+
avgTtftMs: successful > 0 ? Number(bucket.ttftMsSum || 0) / successful : 0,
|
|
463
|
+
avgLatencyMs: requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0,
|
|
464
|
+
ttftMsSum: Number(bucket.ttftMsSum || 0),
|
|
465
|
+
latencyMsSum: Number(bucket.latencyMsSum || 0),
|
|
466
|
+
model: bucket.model || '',
|
|
467
|
+
accountId: bucket.accountId || '',
|
|
468
|
+
lastAt: Number(bucket.lastAt || 0),
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
groups.sort((a, b) => groupBy === 'session'
|
|
472
|
+
? b.lastAt - a.lastAt
|
|
473
|
+
: (b.totalTokens - a.totalTokens) || (b.requests - a.requests))
|
|
474
|
+
|
|
475
|
+
const totals = emptyBucket()
|
|
476
|
+
for (const group of groups) {
|
|
477
|
+
totals.requests += group.requests
|
|
478
|
+
totals.successful += group.successful
|
|
479
|
+
totals.failed += group.failed
|
|
480
|
+
totals.rateLimited429 += group.rateLimited429
|
|
481
|
+
totals.promptTokens += group.promptTokens
|
|
482
|
+
totals.completionTokens += group.completionTokens
|
|
483
|
+
totals.reasoningTokens += group.reasoningTokens
|
|
484
|
+
totals.cacheReadTokens += group.cacheReadTokens
|
|
485
|
+
totals.cacheWriteTokens += group.cacheWriteTokens
|
|
486
|
+
totals.totalTokens += group.totalTokens
|
|
487
|
+
totals.costUsd += group.costUsd
|
|
488
|
+
totals.pricedRequests += group.pricedRequests
|
|
489
|
+
totals.unpricedRequests += group.unpricedRequests
|
|
490
|
+
totals.ttftMsSum += group.ttftMsSum
|
|
491
|
+
totals.latencyMsSum += group.latencyMsSum
|
|
492
|
+
if (group.lastAt) {
|
|
493
|
+
totals.firstAt = totals.firstAt ? Math.min(totals.firstAt, group.lastAt) : group.lastAt
|
|
494
|
+
totals.lastAt = Math.max(totals.lastAt, group.lastAt)
|
|
495
|
+
}
|
|
496
|
+
if (group.model) totals.model = group.model
|
|
497
|
+
if (group.accountId) totals.accountId = group.accountId
|
|
498
|
+
}
|
|
499
|
+
totals.successRate = totals.requests > 0 ? (totals.successful / totals.requests) * 100 : 0
|
|
500
|
+
totals.avgTtftMs = totals.successful > 0 ? totals.ttftMsSum / totals.successful : 0
|
|
501
|
+
totals.avgLatencyMs = totals.requests > 0 ? totals.latencyMsSum / totals.requests : 0
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
range: { from, to },
|
|
505
|
+
totals,
|
|
506
|
+
groups: groups.slice(0, limit),
|
|
507
|
+
unpriced: Number(totals.unpricedRequests || 0),
|
|
508
|
+
heatmap: getHeatmap(query.heatmapDays, to, query.heatmapModel),
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Export CSV. The compact form keeps the historical schema; `detailed`
|
|
514
|
+
* appends cache, cost, TTFT and latency columns.
|
|
515
|
+
* @param {boolean} [detailed]
|
|
318
516
|
*/
|
|
319
|
-
export function exportStatsAsCsv() {
|
|
320
|
-
const
|
|
517
|
+
export function exportStatsAsCsv(detailed = false) {
|
|
518
|
+
const header = detailed
|
|
519
|
+
? 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,CacheReadTokens,CacheWriteTokens,TotalTokens,CostUsd,TtftMs,LatencyMs'
|
|
520
|
+
: 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens'
|
|
521
|
+
const lines = [header]
|
|
321
522
|
const addRows = (dimension, obj) => {
|
|
322
523
|
for (const [key, val] of Object.entries(obj || {})) {
|
|
323
|
-
|
|
524
|
+
if (detailed) {
|
|
525
|
+
lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
|
|
526
|
+
} else {
|
|
527
|
+
lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
|
|
528
|
+
}
|
|
324
529
|
}
|
|
325
530
|
}
|
|
326
531
|
const totals = inMemoryStats.totals || {}
|
|
327
|
-
|
|
532
|
+
if (detailed) {
|
|
533
|
+
lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.cacheReadTokens || 0},${totals.cacheWriteTokens || 0},${totals.totalTokens || 0},${Number(totals.costUsd || 0).toFixed(6)},${Math.round(totals.ttftMsSum || 0)},${Math.round(totals.latencyMsSum || 0)}`)
|
|
534
|
+
} else {
|
|
535
|
+
lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
|
|
536
|
+
}
|
|
328
537
|
addRows('Day', inMemoryStats.byDay)
|
|
329
538
|
addRows('Month', inMemoryStats.byMonth)
|
|
330
539
|
addRows('Model', inMemoryStats.byModel)
|
|
331
540
|
addRows('Account', inMemoryStats.byAccount)
|
|
541
|
+
if (detailed) {
|
|
542
|
+
addRows('Session', inMemoryStats.bySession)
|
|
543
|
+
addRows('Hour', inMemoryStats.byHour)
|
|
544
|
+
}
|
|
332
545
|
return lines.join('\n')
|
|
333
546
|
}
|