@oh-my-pi/omp-stats 6.9.69
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 +82 -0
- package/package.json +66 -0
- package/src/aggregator.ts +125 -0
- package/src/client/App.tsx +168 -0
- package/src/client/api.ts +33 -0
- package/src/client/components/RequestDetail.tsx +88 -0
- package/src/client/components/RequestList.tsx +71 -0
- package/src/client/components/StatCard.tsx +30 -0
- package/src/client/index.tsx +5 -0
- package/src/client/types.ts +82 -0
- package/src/db.ts +396 -0
- package/src/index.ts +168 -0
- package/src/parser.ts +166 -0
- package/src/server.ts +147 -0
- package/src/types.ts +139 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side type definitions.
|
|
3
|
+
* Duplicated from ../types.ts to avoid pulling in server dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface Usage {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
cacheWrite: number;
|
|
11
|
+
totalTokens: number;
|
|
12
|
+
cost: {
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
total: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MessageStats {
|
|
22
|
+
id?: number;
|
|
23
|
+
sessionFile: string;
|
|
24
|
+
entryId: string;
|
|
25
|
+
folder: string;
|
|
26
|
+
model: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
api: string;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
duration: number | null;
|
|
31
|
+
ttft: number | null;
|
|
32
|
+
stopReason: string;
|
|
33
|
+
errorMessage: string | null;
|
|
34
|
+
usage: Usage;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RequestDetails extends MessageStats {
|
|
38
|
+
messages: unknown[];
|
|
39
|
+
output: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AggregatedStats {
|
|
43
|
+
totalRequests: number;
|
|
44
|
+
successfulRequests: number;
|
|
45
|
+
failedRequests: number;
|
|
46
|
+
errorRate: number;
|
|
47
|
+
totalInputTokens: number;
|
|
48
|
+
totalOutputTokens: number;
|
|
49
|
+
totalCacheReadTokens: number;
|
|
50
|
+
totalCacheWriteTokens: number;
|
|
51
|
+
cacheRate: number;
|
|
52
|
+
totalCost: number;
|
|
53
|
+
avgDuration: number | null;
|
|
54
|
+
avgTtft: number | null;
|
|
55
|
+
avgTokensPerSecond: number | null;
|
|
56
|
+
firstTimestamp: number;
|
|
57
|
+
lastTimestamp: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ModelStats extends AggregatedStats {
|
|
61
|
+
model: string;
|
|
62
|
+
provider: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FolderStats extends AggregatedStats {
|
|
66
|
+
folder: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TimeSeriesPoint {
|
|
70
|
+
timestamp: number;
|
|
71
|
+
requests: number;
|
|
72
|
+
errors: number;
|
|
73
|
+
tokens: number;
|
|
74
|
+
cost: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DashboardStats {
|
|
78
|
+
overall: AggregatedStats;
|
|
79
|
+
byModel: ModelStats[];
|
|
80
|
+
byFolder: FolderStats[];
|
|
81
|
+
timeSeries: TimeSeriesPoint[];
|
|
82
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AggregatedStats, FolderStats, MessageStats, ModelStats, TimeSeriesPoint } from "./types";
|
|
6
|
+
|
|
7
|
+
const DB_PATH = join(homedir(), ".omp", "stats.db");
|
|
8
|
+
|
|
9
|
+
let db: Database | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize the database and create tables.
|
|
13
|
+
*/
|
|
14
|
+
export async function initDb(): Promise<Database> {
|
|
15
|
+
if (db) return db;
|
|
16
|
+
|
|
17
|
+
// Ensure directory exists
|
|
18
|
+
await mkdir(join(homedir(), ".omp"), { recursive: true });
|
|
19
|
+
|
|
20
|
+
db = new Database(DB_PATH);
|
|
21
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
22
|
+
|
|
23
|
+
// Create tables
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
session_file TEXT NOT NULL,
|
|
28
|
+
entry_id TEXT NOT NULL,
|
|
29
|
+
folder TEXT NOT NULL,
|
|
30
|
+
model TEXT NOT NULL,
|
|
31
|
+
provider TEXT NOT NULL,
|
|
32
|
+
api TEXT NOT NULL,
|
|
33
|
+
timestamp INTEGER NOT NULL,
|
|
34
|
+
duration INTEGER,
|
|
35
|
+
ttft INTEGER,
|
|
36
|
+
stop_reason TEXT NOT NULL,
|
|
37
|
+
error_message TEXT,
|
|
38
|
+
input_tokens INTEGER NOT NULL,
|
|
39
|
+
output_tokens INTEGER NOT NULL,
|
|
40
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
41
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
42
|
+
total_tokens INTEGER NOT NULL,
|
|
43
|
+
cost_input REAL NOT NULL,
|
|
44
|
+
cost_output REAL NOT NULL,
|
|
45
|
+
cost_cache_read REAL NOT NULL,
|
|
46
|
+
cost_cache_write REAL NOT NULL,
|
|
47
|
+
cost_total REAL NOT NULL,
|
|
48
|
+
UNIQUE(session_file, entry_id)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS file_offsets (
|
|
57
|
+
session_file TEXT PRIMARY KEY,
|
|
58
|
+
offset INTEGER NOT NULL,
|
|
59
|
+
last_modified INTEGER NOT NULL
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
return db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the stored offset for a session file.
|
|
68
|
+
*/
|
|
69
|
+
export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
|
|
70
|
+
if (!db) return null;
|
|
71
|
+
|
|
72
|
+
const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
|
|
73
|
+
const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
|
|
74
|
+
|
|
75
|
+
return row ? { offset: row.offset, lastModified: row.last_modified } : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Update the stored offset for a session file.
|
|
80
|
+
*/
|
|
81
|
+
export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
|
|
82
|
+
if (!db) return;
|
|
83
|
+
|
|
84
|
+
const stmt = db.prepare(`
|
|
85
|
+
INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
|
|
86
|
+
VALUES (?, ?, ?)
|
|
87
|
+
`);
|
|
88
|
+
stmt.run(sessionFile, offset, lastModified);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Insert message stats into the database.
|
|
93
|
+
*/
|
|
94
|
+
export function insertMessageStats(stats: MessageStats[]): number {
|
|
95
|
+
if (!db || stats.length === 0) return 0;
|
|
96
|
+
|
|
97
|
+
const stmt = db.prepare(`
|
|
98
|
+
INSERT OR IGNORE INTO messages (
|
|
99
|
+
session_file, entry_id, folder, model, provider, api, timestamp,
|
|
100
|
+
duration, ttft, stop_reason, error_message,
|
|
101
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
|
|
102
|
+
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
|
|
103
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
`);
|
|
105
|
+
|
|
106
|
+
let inserted = 0;
|
|
107
|
+
const insert = db.transaction(() => {
|
|
108
|
+
for (const s of stats) {
|
|
109
|
+
const result = stmt.run(
|
|
110
|
+
s.sessionFile,
|
|
111
|
+
s.entryId,
|
|
112
|
+
s.folder,
|
|
113
|
+
s.model,
|
|
114
|
+
s.provider,
|
|
115
|
+
s.api,
|
|
116
|
+
s.timestamp,
|
|
117
|
+
s.duration,
|
|
118
|
+
s.ttft,
|
|
119
|
+
s.stopReason,
|
|
120
|
+
s.errorMessage,
|
|
121
|
+
s.usage.input,
|
|
122
|
+
s.usage.output,
|
|
123
|
+
s.usage.cacheRead,
|
|
124
|
+
s.usage.cacheWrite,
|
|
125
|
+
s.usage.totalTokens,
|
|
126
|
+
s.usage.cost.input,
|
|
127
|
+
s.usage.cost.output,
|
|
128
|
+
s.usage.cost.cacheRead,
|
|
129
|
+
s.usage.cost.cacheWrite,
|
|
130
|
+
s.usage.cost.total,
|
|
131
|
+
);
|
|
132
|
+
if (result.changes > 0) inserted++;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
insert();
|
|
137
|
+
return inserted;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build aggregated stats from query results.
|
|
142
|
+
*/
|
|
143
|
+
function buildAggregatedStats(rows: any[]): AggregatedStats {
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
totalRequests: 0,
|
|
147
|
+
successfulRequests: 0,
|
|
148
|
+
failedRequests: 0,
|
|
149
|
+
errorRate: 0,
|
|
150
|
+
totalInputTokens: 0,
|
|
151
|
+
totalOutputTokens: 0,
|
|
152
|
+
totalCacheReadTokens: 0,
|
|
153
|
+
totalCacheWriteTokens: 0,
|
|
154
|
+
cacheRate: 0,
|
|
155
|
+
totalCost: 0,
|
|
156
|
+
avgDuration: null,
|
|
157
|
+
avgTtft: null,
|
|
158
|
+
avgTokensPerSecond: null,
|
|
159
|
+
firstTimestamp: 0,
|
|
160
|
+
lastTimestamp: 0,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const row = rows[0];
|
|
165
|
+
const totalRequests = row.total_requests || 0;
|
|
166
|
+
const failedRequests = row.failed_requests || 0;
|
|
167
|
+
const successfulRequests = totalRequests - failedRequests;
|
|
168
|
+
const totalInputTokens = row.total_input_tokens || 0;
|
|
169
|
+
const totalCacheReadTokens = row.total_cache_read_tokens || 0;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
totalRequests,
|
|
173
|
+
successfulRequests,
|
|
174
|
+
failedRequests,
|
|
175
|
+
errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
|
|
176
|
+
totalInputTokens,
|
|
177
|
+
totalOutputTokens: row.total_output_tokens || 0,
|
|
178
|
+
totalCacheReadTokens,
|
|
179
|
+
totalCacheWriteTokens: row.total_cache_write_tokens || 0,
|
|
180
|
+
cacheRate:
|
|
181
|
+
totalInputTokens + totalCacheReadTokens > 0
|
|
182
|
+
? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
|
|
183
|
+
: 0,
|
|
184
|
+
totalCost: row.total_cost || 0,
|
|
185
|
+
avgDuration: row.avg_duration,
|
|
186
|
+
avgTtft: row.avg_ttft,
|
|
187
|
+
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
188
|
+
firstTimestamp: row.first_timestamp || 0,
|
|
189
|
+
lastTimestamp: row.last_timestamp || 0,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get overall aggregated stats.
|
|
195
|
+
*/
|
|
196
|
+
export function getOverallStats(): AggregatedStats {
|
|
197
|
+
if (!db) return buildAggregatedStats([]);
|
|
198
|
+
|
|
199
|
+
const stmt = db.prepare(`
|
|
200
|
+
SELECT
|
|
201
|
+
COUNT(*) as total_requests,
|
|
202
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
203
|
+
SUM(input_tokens) as total_input_tokens,
|
|
204
|
+
SUM(output_tokens) as total_output_tokens,
|
|
205
|
+
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
206
|
+
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
207
|
+
SUM(cost_total) as total_cost,
|
|
208
|
+
AVG(duration) as avg_duration,
|
|
209
|
+
AVG(ttft) as avg_ttft,
|
|
210
|
+
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
211
|
+
MIN(timestamp) as first_timestamp,
|
|
212
|
+
MAX(timestamp) as last_timestamp
|
|
213
|
+
FROM messages
|
|
214
|
+
`);
|
|
215
|
+
|
|
216
|
+
const rows = stmt.all();
|
|
217
|
+
return buildAggregatedStats(rows);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get stats grouped by model.
|
|
222
|
+
*/
|
|
223
|
+
export function getStatsByModel(): ModelStats[] {
|
|
224
|
+
if (!db) return [];
|
|
225
|
+
|
|
226
|
+
const stmt = db.prepare(`
|
|
227
|
+
SELECT
|
|
228
|
+
model,
|
|
229
|
+
provider,
|
|
230
|
+
COUNT(*) as total_requests,
|
|
231
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
232
|
+
SUM(input_tokens) as total_input_tokens,
|
|
233
|
+
SUM(output_tokens) as total_output_tokens,
|
|
234
|
+
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
235
|
+
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
236
|
+
SUM(cost_total) as total_cost,
|
|
237
|
+
AVG(duration) as avg_duration,
|
|
238
|
+
AVG(ttft) as avg_ttft,
|
|
239
|
+
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
240
|
+
MIN(timestamp) as first_timestamp,
|
|
241
|
+
MAX(timestamp) as last_timestamp
|
|
242
|
+
FROM messages
|
|
243
|
+
GROUP BY model, provider
|
|
244
|
+
ORDER BY total_requests DESC
|
|
245
|
+
`);
|
|
246
|
+
|
|
247
|
+
const rows = stmt.all() as any[];
|
|
248
|
+
return rows.map((row) => ({
|
|
249
|
+
model: row.model,
|
|
250
|
+
provider: row.provider,
|
|
251
|
+
...buildAggregatedStats([row]),
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get stats grouped by folder.
|
|
257
|
+
*/
|
|
258
|
+
export function getStatsByFolder(): FolderStats[] {
|
|
259
|
+
if (!db) return [];
|
|
260
|
+
|
|
261
|
+
const stmt = db.prepare(`
|
|
262
|
+
SELECT
|
|
263
|
+
folder,
|
|
264
|
+
COUNT(*) as total_requests,
|
|
265
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
266
|
+
SUM(input_tokens) as total_input_tokens,
|
|
267
|
+
SUM(output_tokens) as total_output_tokens,
|
|
268
|
+
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
269
|
+
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
270
|
+
SUM(cost_total) as total_cost,
|
|
271
|
+
AVG(duration) as avg_duration,
|
|
272
|
+
AVG(ttft) as avg_ttft,
|
|
273
|
+
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
274
|
+
MIN(timestamp) as first_timestamp,
|
|
275
|
+
MAX(timestamp) as last_timestamp
|
|
276
|
+
FROM messages
|
|
277
|
+
GROUP BY folder
|
|
278
|
+
ORDER BY total_requests DESC
|
|
279
|
+
`);
|
|
280
|
+
|
|
281
|
+
const rows = stmt.all() as any[];
|
|
282
|
+
return rows.map((row) => ({
|
|
283
|
+
folder: row.folder,
|
|
284
|
+
...buildAggregatedStats([row]),
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get hourly time series data.
|
|
290
|
+
*/
|
|
291
|
+
export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
|
|
292
|
+
if (!db) return [];
|
|
293
|
+
|
|
294
|
+
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
|
295
|
+
|
|
296
|
+
const stmt = db.prepare(`
|
|
297
|
+
SELECT
|
|
298
|
+
(timestamp / 3600000) * 3600000 as bucket,
|
|
299
|
+
COUNT(*) as requests,
|
|
300
|
+
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
|
|
301
|
+
SUM(total_tokens) as tokens,
|
|
302
|
+
SUM(cost_total) as cost
|
|
303
|
+
FROM messages
|
|
304
|
+
WHERE timestamp >= ?
|
|
305
|
+
GROUP BY bucket
|
|
306
|
+
ORDER BY bucket ASC
|
|
307
|
+
`);
|
|
308
|
+
|
|
309
|
+
const rows = stmt.all(cutoff) as any[];
|
|
310
|
+
return rows.map((row) => ({
|
|
311
|
+
timestamp: row.bucket,
|
|
312
|
+
requests: row.requests,
|
|
313
|
+
errors: row.errors,
|
|
314
|
+
tokens: row.tokens,
|
|
315
|
+
cost: row.cost,
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get total message count.
|
|
321
|
+
*/
|
|
322
|
+
export function getMessageCount(): number {
|
|
323
|
+
if (!db) return 0;
|
|
324
|
+
const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
|
|
325
|
+
const row = stmt.get() as { count: number };
|
|
326
|
+
return row.count;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Close the database connection.
|
|
331
|
+
*/
|
|
332
|
+
export function closeDb(): void {
|
|
333
|
+
if (db) {
|
|
334
|
+
db.close();
|
|
335
|
+
db = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function rowToMessageStats(row: any): MessageStats {
|
|
340
|
+
return {
|
|
341
|
+
id: row.id,
|
|
342
|
+
sessionFile: row.session_file,
|
|
343
|
+
entryId: row.entry_id,
|
|
344
|
+
folder: row.folder,
|
|
345
|
+
model: row.model,
|
|
346
|
+
provider: row.provider,
|
|
347
|
+
api: row.api,
|
|
348
|
+
timestamp: row.timestamp,
|
|
349
|
+
duration: row.duration,
|
|
350
|
+
ttft: row.ttft,
|
|
351
|
+
stopReason: row.stop_reason as any,
|
|
352
|
+
errorMessage: row.error_message,
|
|
353
|
+
usage: {
|
|
354
|
+
input: row.input_tokens,
|
|
355
|
+
output: row.output_tokens,
|
|
356
|
+
cacheRead: row.cache_read_tokens,
|
|
357
|
+
cacheWrite: row.cache_write_tokens,
|
|
358
|
+
totalTokens: row.total_tokens,
|
|
359
|
+
cost: {
|
|
360
|
+
input: row.cost_input,
|
|
361
|
+
output: row.cost_output,
|
|
362
|
+
cacheRead: row.cost_cache_read,
|
|
363
|
+
cacheWrite: row.cost_cache_write,
|
|
364
|
+
total: row.cost_total,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function getRecentRequests(limit = 100): MessageStats[] {
|
|
371
|
+
if (!db) return [];
|
|
372
|
+
const stmt = db.prepare(`
|
|
373
|
+
SELECT * FROM messages
|
|
374
|
+
ORDER BY timestamp DESC
|
|
375
|
+
LIMIT ?
|
|
376
|
+
`);
|
|
377
|
+
return (stmt.all(limit) as any[]).map(rowToMessageStats);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function getRecentErrors(limit = 100): MessageStats[] {
|
|
381
|
+
if (!db) return [];
|
|
382
|
+
const stmt = db.prepare(`
|
|
383
|
+
SELECT * FROM messages
|
|
384
|
+
WHERE stop_reason = 'error'
|
|
385
|
+
ORDER BY timestamp DESC
|
|
386
|
+
LIMIT ?
|
|
387
|
+
`);
|
|
388
|
+
return (stmt.all(limit) as any[]).map(rowToMessageStats);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function getMessageById(id: number): MessageStats | null {
|
|
392
|
+
if (!db) return null;
|
|
393
|
+
const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
|
|
394
|
+
const row = stmt.get(id);
|
|
395
|
+
return row ? rowToMessageStats(row) : null;
|
|
396
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
|
|
5
|
+
import { closeDb } from "./db";
|
|
6
|
+
import { startServer } from "./server";
|
|
7
|
+
|
|
8
|
+
export { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
|
|
9
|
+
export type {
|
|
10
|
+
AggregatedStats,
|
|
11
|
+
DashboardStats,
|
|
12
|
+
FolderStats,
|
|
13
|
+
MessageStats,
|
|
14
|
+
ModelStats,
|
|
15
|
+
TimeSeriesPoint,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format a number with appropriate suffix (K, M, etc.)
|
|
20
|
+
*/
|
|
21
|
+
function formatNumber(n: number): string {
|
|
22
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
23
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
24
|
+
return n.toFixed(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format cost in dollars.
|
|
29
|
+
*/
|
|
30
|
+
function formatCost(n: number): string {
|
|
31
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
32
|
+
if (n < 1) return `$${n.toFixed(3)}`;
|
|
33
|
+
return `$${n.toFixed(2)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format duration in ms to human-readable.
|
|
38
|
+
*/
|
|
39
|
+
function formatDuration(ms: number | null): string {
|
|
40
|
+
if (ms === null) return "-";
|
|
41
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
42
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format percentage.
|
|
47
|
+
*/
|
|
48
|
+
function formatPercent(n: number): string {
|
|
49
|
+
return `${(n * 100).toFixed(1)}%`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Print stats summary to console.
|
|
54
|
+
*/
|
|
55
|
+
async function printStats(): Promise<void> {
|
|
56
|
+
const stats = await getDashboardStats();
|
|
57
|
+
const { overall, byModel, byFolder } = stats;
|
|
58
|
+
|
|
59
|
+
console.log("\n=== AI Usage Statistics ===\n");
|
|
60
|
+
|
|
61
|
+
console.log("Overall:");
|
|
62
|
+
console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
|
|
63
|
+
console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
|
|
64
|
+
console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
|
|
65
|
+
console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
|
|
66
|
+
console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
|
|
67
|
+
console.log(` Avg Duration: ${formatDuration(overall.avgDuration)}`);
|
|
68
|
+
console.log(` Avg TTFT: ${formatDuration(overall.avgTtft)}`);
|
|
69
|
+
if (overall.avgTokensPerSecond !== null) {
|
|
70
|
+
console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (byModel.length > 0) {
|
|
74
|
+
console.log("\nBy Model:");
|
|
75
|
+
for (const m of byModel.slice(0, 10)) {
|
|
76
|
+
console.log(
|
|
77
|
+
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (byFolder.length > 0) {
|
|
83
|
+
console.log("\nBy Folder:");
|
|
84
|
+
for (const f of byFolder.slice(0, 10)) {
|
|
85
|
+
console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Main CLI entry point.
|
|
94
|
+
*/
|
|
95
|
+
async function main(): Promise<void> {
|
|
96
|
+
const { values } = parseArgs({
|
|
97
|
+
options: {
|
|
98
|
+
port: { type: "string", short: "p", default: "3847" },
|
|
99
|
+
json: { type: "boolean", short: "j", default: false },
|
|
100
|
+
sync: { type: "boolean", short: "s", default: false },
|
|
101
|
+
help: { type: "boolean", short: "h", default: false },
|
|
102
|
+
},
|
|
103
|
+
allowPositionals: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (values.help) {
|
|
107
|
+
console.log(`
|
|
108
|
+
omp-stats - AI Usage Statistics Dashboard
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
omp-stats [options]
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
-p, --port <port> Port for the dashboard server (default: 3847)
|
|
115
|
+
-j, --json Output stats as JSON and exit
|
|
116
|
+
-s, --sync Sync session files and show summary
|
|
117
|
+
-h, --help Show this help message
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
omp-stats # Start dashboard server
|
|
121
|
+
omp-stats --json # Print stats as JSON
|
|
122
|
+
omp-stats --port 8080 # Start on custom port
|
|
123
|
+
omp-stats --sync # Sync and show summary
|
|
124
|
+
`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Sync first
|
|
130
|
+
console.log("Syncing session files...");
|
|
131
|
+
const { processed, files } = await syncAllSessions();
|
|
132
|
+
const total = await getTotalMessageCount();
|
|
133
|
+
console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
|
|
134
|
+
|
|
135
|
+
if (values.json) {
|
|
136
|
+
const stats = await getDashboardStats();
|
|
137
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (values.sync) {
|
|
142
|
+
await printStats();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Start server
|
|
147
|
+
const port = parseInt(values.port || "3847", 10);
|
|
148
|
+
const { port: actualPort } = startServer(port);
|
|
149
|
+
console.log(`Dashboard available at: http://localhost:${actualPort}`);
|
|
150
|
+
console.log("Press Ctrl+C to stop\n");
|
|
151
|
+
|
|
152
|
+
// Keep process running
|
|
153
|
+
process.on("SIGINT", () => {
|
|
154
|
+
console.log("\nShutting down...");
|
|
155
|
+
closeDb();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error("Error:", error);
|
|
160
|
+
closeDb();
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run if executed directly
|
|
166
|
+
if (import.meta.main) {
|
|
167
|
+
main();
|
|
168
|
+
}
|