@krtclcdy/tersio 2.25.5 → 2.25.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/README.md +17 -36
- package/dashboard/dist/index.html +15 -15
- package/dist/cli/backups.js +7 -14
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +28 -12
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +66 -35
- package/dist/cli/doctor-fix.js +204 -19
- package/dist/cli/doctor.js +226 -39
- package/dist/cli/hosts.js +25 -5
- package/dist/cli/install.js +144 -48
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +87 -7
- package/dist/cli/profile.js +13 -8
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +3 -3
- package/dist/cli/update.js +6 -7
- package/dist/cli/usage.js +6 -9
- package/dist/extensions/caveman/index.js +4 -2
- package/dist/extensions/caveman/rule-megacave.md +5 -3
- package/dist/extensions/caveman/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +55 -45
- package/dist/extensions/combo/index.js +62 -21
- package/dist/extensions/commands/index.js +1 -1
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/rtk/filter.js +0 -1
- package/dist/extensions/rtk/session.js +4 -4
- package/dist/extensions/server.js +90 -6
- package/dist/extensions/shared/agent-stats.js +214 -278
- package/dist/extensions/shared/brand-marks.js +33 -34
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +11 -6
- package/dist/extensions/shared/pricing.js +8 -16
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +53 -41
- package/dist/extensions/shared/usage-store.js +111 -52
- package/dist/extensions/shared/utils.js +5 -5
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/updater/index.js +3 -6
- package/dist/tersio.js +8 -3
- package/extensions/caveman/index.ts +4 -2
- package/extensions/caveman/rule-megacave.md +5 -3
- package/extensions/caveman/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +55 -45
- package/extensions/combo/index.ts +62 -19
- package/extensions/commands/index.ts +1 -1
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/rtk/filter.ts +0 -1
- package/extensions/rtk/session.ts +4 -4
- package/extensions/server.ts +72 -7
- package/extensions/shared/agent-stats.ts +226 -278
- package/extensions/shared/brand-marks.ts +34 -34
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +12 -6
- package/extensions/shared/pricing.ts +10 -26
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +60 -47
- package/extensions/shared/usage-store.ts +120 -52
- package/extensions/shared/utils.ts +5 -5
- package/extensions/timing/index.ts +45 -0
- package/extensions/updater/index.ts +3 -8
- package/package.json +17 -8
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Today that is omp's stats.db and agent.db, so the naming stays host-neutral. Every metric
|
|
3
|
-
// matches @oh-my-pi/omp-stats: same SQL facts, same rules. Missing sqlite3 or a
|
|
4
|
-
// missing table degrades to zeros, never to a thrown error.
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
// Read-only aggregate over the usage store; missing SQLite, table, or metric reads as zeros.
|
|
6
2
|
import fs from 'node:fs';
|
|
7
3
|
import os from 'node:os';
|
|
8
4
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
5
|
+
import { sqliteRead } from './sqlite.js';
|
|
6
|
+
import { usageDbPath } from './usage-store.js';
|
|
10
7
|
export const RANGES = ['1h', '24h', '7d', '30d', '90d', 'all'];
|
|
11
8
|
export function isRangeKey(v) {
|
|
12
9
|
return typeof v === 'string' && RANGES.includes(v);
|
|
@@ -30,7 +27,7 @@ const VIEW_PARTS = {
|
|
|
30
27
|
costs: ['overall', 'byModel', 'series', 'modelSeries'],
|
|
31
28
|
// The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
|
|
32
29
|
carbon: ['overall', 'byModel'],
|
|
33
|
-
requests: ['recent'],
|
|
30
|
+
requests: ['overall', 'recent'],
|
|
34
31
|
errors: ['errors', 'overall', 'byModel', 'recent'],
|
|
35
32
|
traces: ['traces'],
|
|
36
33
|
tools: ['overall', 'tools'],
|
|
@@ -62,12 +59,83 @@ const TRACE_LIMIT = 300;
|
|
|
62
59
|
const TOOL_MODEL_LIMIT = 300;
|
|
63
60
|
const SERIES_PROVIDERS = 8;
|
|
64
61
|
const SERIES_MODELS = 12;
|
|
62
|
+
/** The store every metric but subscription quota reads, kept in step by `tersio usage`. */
|
|
65
63
|
export function statsDbPath() {
|
|
66
|
-
return process.env.TERSIO_AGENT_STATS_DB ??
|
|
64
|
+
return process.env.TERSIO_AGENT_STATS_DB ?? usageDbPath();
|
|
67
65
|
}
|
|
66
|
+
/** Quota snapshots. Only omp records these, so this stays omp's own database. */
|
|
68
67
|
export function agentDbPath() {
|
|
69
68
|
return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
|
|
70
69
|
}
|
|
70
|
+
/** The row shape every query groups by: a message joined to its file, since path and facts live apart. */
|
|
71
|
+
const SOURCE = 'messages m JOIN files f ON f.id = m.file_id';
|
|
72
|
+
/** The transcript path with any host-side row suffix removed, so one file groups as one session. */
|
|
73
|
+
const FILE_KEY = `substr(f.path, 1, instr(f.path || '#', '#') - 1)`;
|
|
74
|
+
/** Model-id spellings whose dashboard mark lives under another name; the first segment names the gateway. */
|
|
75
|
+
const PROVIDER_ALIASES = {
|
|
76
|
+
'opencode-zen': 'opencode',
|
|
77
|
+
'opencode-go': 'opencode',
|
|
78
|
+
'gmi-cloud': 'gmicloud',
|
|
79
|
+
'charm-hyper': 'charmhyper',
|
|
80
|
+
'kimi-code': 'kimi',
|
|
81
|
+
'openai-codex': 'openai',
|
|
82
|
+
'github-copilot': 'githubcopilot',
|
|
83
|
+
'ollama-cloud': 'ollama',
|
|
84
|
+
'amd-radeon-cloud-cn': 'amd',
|
|
85
|
+
'google-antigravity': 'google',
|
|
86
|
+
devin: 'cognition',
|
|
87
|
+
};
|
|
88
|
+
/** SQL: the gateway a model id names, from its first segment. Empty when the id carries none, not a guess. */
|
|
89
|
+
function providerExpr(model) {
|
|
90
|
+
const seg = `substr(${model}, 1, instr(${model} || '/', '/') - 1)`;
|
|
91
|
+
const named = `ltrim(${seg}, '~')`;
|
|
92
|
+
const cases = Object.entries(PROVIDER_ALIASES)
|
|
93
|
+
.map(([from, to]) => `WHEN '${from}' THEN '${to}'`)
|
|
94
|
+
.join(' ');
|
|
95
|
+
return `CASE WHEN instr(${model}, '/') = 0 THEN '' ELSE CASE ${named} ${cases} ELSE ${named} END END`;
|
|
96
|
+
}
|
|
97
|
+
/** SQL: the project a transcript belongs to, named by its session folder. A flat session tree uses its data dir. */
|
|
98
|
+
function projectExpr(file) {
|
|
99
|
+
const marker = `instr(${file}, '/sessions/')`;
|
|
100
|
+
const seg = `substr(${file}, ${marker} + 10, instr(substr(${file}, ${marker} + 10) || '/', '/') - 1)`;
|
|
101
|
+
const base = `replace(${file}, rtrim(${file}, replace(${file}, '/', '')), '')`;
|
|
102
|
+
const dir = `substr(${file}, 1, length(${file}) - length(${base}) - 1)`;
|
|
103
|
+
const dirBase = `replace(${dir}, rtrim(${dir}, replace(${dir}, '/', '')), '')`;
|
|
104
|
+
return `CASE WHEN ${marker} > 0 THEN ${seg} ELSE ${dirBase} END`;
|
|
105
|
+
}
|
|
106
|
+
/** Every column a query reads, mapped to the store's schema in one place. */
|
|
107
|
+
const COL = {
|
|
108
|
+
session: FILE_KEY,
|
|
109
|
+
entry: `COALESCE(m.id, '')`,
|
|
110
|
+
folder: projectExpr(FILE_KEY),
|
|
111
|
+
model: 'm.model',
|
|
112
|
+
provider: providerExpr('m.model'),
|
|
113
|
+
/** No route name is stored, and the Requests page reads one. */
|
|
114
|
+
api: "''",
|
|
115
|
+
timestamp: 'm.t',
|
|
116
|
+
// Bucketing form of the timestamp. REAL division does not floor, so the cast is needed.
|
|
117
|
+
bucket: 'CAST(COALESCE(m.t, 0) AS INTEGER)',
|
|
118
|
+
duration: 'm.d',
|
|
119
|
+
/** First-token latency. A host that records none leaves it NULL, which reads null, never zero. */
|
|
120
|
+
ttft: 'm.tf',
|
|
121
|
+
stopReason: `CASE m.st WHEN 'error' THEN 'error' WHEN 'aborted' THEN 'aborted' ELSE 'stop' END`,
|
|
122
|
+
errorMessage: 'm.note',
|
|
123
|
+
input: 'm.i',
|
|
124
|
+
output: 'm.o',
|
|
125
|
+
cacheRead: 'm.cr',
|
|
126
|
+
cacheWrite: 'm.cw',
|
|
127
|
+
total: '(m.i + m.o + m.cr + m.cw)',
|
|
128
|
+
/** The cost the host reported. A model the host priced nothing for reads $0.00 by design. */
|
|
129
|
+
costTotal: 'COALESCE(m.usd, 0)',
|
|
130
|
+
agentType: `COALESCE(m.a, 'main')`,
|
|
131
|
+
host: `COALESCE(m.h, '')`,
|
|
132
|
+
/** The store holds no per-component split, so the remainder rule below finds nothing to move. */
|
|
133
|
+
costInput: '0',
|
|
134
|
+
costCacheRead: '0',
|
|
135
|
+
costCacheWrite: '0',
|
|
136
|
+
/** No route is a subscription route here, so nothing is ever flagged unpriced. */
|
|
137
|
+
unpriced: '0',
|
|
138
|
+
};
|
|
71
139
|
/** Output cost is the remainder: omp prices the whole request and the three cache-side parts. */
|
|
72
140
|
function costMixOf(f) {
|
|
73
141
|
const input = f.costInput;
|
|
@@ -102,95 +170,27 @@ export function emptyAgentStats(range = '24h') {
|
|
|
102
170
|
tools: [], toolsByModel: [], toolSeries: [],
|
|
103
171
|
usageSeries: [], windowInsights: [], providerHourly: [],
|
|
104
172
|
requestStats: {
|
|
105
|
-
requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
|
|
173
|
+
requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0, shipped: 0,
|
|
106
174
|
medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
|
|
107
175
|
},
|
|
108
176
|
errors: [],
|
|
109
177
|
};
|
|
110
178
|
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The omp databases run in WAL mode. Ordered fallbacks, each verified against the live pair:
|
|
114
|
-
* `-readonly` needs the `-shm` side file, `immutable=1` silently ignores a WAL, and a copy of
|
|
115
|
-
* db + wal + shm replays the log to the same row count the running server reports.
|
|
116
|
-
*/
|
|
179
|
+
/** Rows from a read-only script, tab-separated: whichever engine this host has answers it. */
|
|
117
180
|
export function sqliteScript(db, script) {
|
|
118
|
-
|
|
119
|
-
throw new Error(`missing database: ${db}`);
|
|
120
|
-
const opts = {
|
|
121
|
-
encoding: 'utf8',
|
|
122
|
-
timeout: 30_000,
|
|
123
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
124
|
-
maxBuffer: SQLITE_READ_BUFFER,
|
|
125
|
-
input: script,
|
|
126
|
-
};
|
|
127
|
-
try {
|
|
128
|
-
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
|
|
129
|
-
}
|
|
130
|
-
catch { /* the side files may be gone; try the next rung */ }
|
|
131
|
-
if (!fs.existsSync(`${db}-wal`)) {
|
|
132
|
-
try {
|
|
133
|
-
return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
|
|
134
|
-
}
|
|
135
|
-
catch { /* fall through to the copy */ }
|
|
136
|
-
}
|
|
137
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-omp-'));
|
|
138
|
-
const target = path.join(dir, path.basename(db));
|
|
139
|
-
try {
|
|
140
|
-
fs.copyFileSync(db, target);
|
|
141
|
-
for (const suffix of ['-wal', '-shm']) {
|
|
142
|
-
if (fs.existsSync(`${db}${suffix}`))
|
|
143
|
-
fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
|
|
144
|
-
}
|
|
145
|
-
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
-
}
|
|
181
|
+
return sqliteRead(db, script);
|
|
150
182
|
}
|
|
151
|
-
|
|
152
|
-
function
|
|
183
|
+
/** One call per query: an unavailable table leaves its key absent, so a partial schema still reads. */
|
|
184
|
+
function readBlocks(db, queries) {
|
|
153
185
|
const map = new Map();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
continue;
|
|
158
|
-
if (line.startsWith(BLOCK)) {
|
|
159
|
-
current = [];
|
|
160
|
-
map.set(line.slice(BLOCK.length), current);
|
|
161
|
-
continue;
|
|
186
|
+
for (const [name, sql] of queries) {
|
|
187
|
+
try {
|
|
188
|
+
map.set(name, sqliteScript(db, sql).split('\n').filter((line) => line.trim()).map((line) => line.split('\t')));
|
|
162
189
|
}
|
|
163
|
-
|
|
164
|
-
current.push(line.split('\t'));
|
|
190
|
+
catch { /* an unavailable table leaves its key absent */ }
|
|
165
191
|
}
|
|
166
192
|
return map;
|
|
167
193
|
}
|
|
168
|
-
/**
|
|
169
|
-
* One sqlite3 call for a group of queries. A failing statement aborts the script, so the
|
|
170
|
-
* partial output is kept and only the missing blocks re-run one at a time.
|
|
171
|
-
*/
|
|
172
|
-
function readBlocks(db, queries) {
|
|
173
|
-
const script = queries.map(([name, sql]) => `SELECT '${BLOCK}${name}';\n${sql};\n`).join('');
|
|
174
|
-
try {
|
|
175
|
-
return blocks(sqliteScript(db, script));
|
|
176
|
-
}
|
|
177
|
-
catch (error) {
|
|
178
|
-
// A failing statement aborts the script, so its stdout holds every block that ran.
|
|
179
|
-
if (!error || typeof error !== 'object' || !('stdout' in error) || typeof error.stdout !== 'string')
|
|
180
|
-
throw error;
|
|
181
|
-
const merged = blocks(error.stdout);
|
|
182
|
-
for (const [name, sql] of queries) {
|
|
183
|
-
if (merged.has(name))
|
|
184
|
-
continue;
|
|
185
|
-
try {
|
|
186
|
-
for (const [k, rows] of blocks(sqliteScript(db, `SELECT '${BLOCK}${name}';\n${sql};\n`)))
|
|
187
|
-
merged.set(k, rows);
|
|
188
|
-
}
|
|
189
|
-
catch { /* an unavailable table leaves its key absent */ }
|
|
190
|
-
}
|
|
191
|
-
return merged;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
194
|
function tableColumns(db, table) {
|
|
195
195
|
try {
|
|
196
196
|
const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
|
|
@@ -214,60 +214,43 @@ function numOrNull(v) {
|
|
|
214
214
|
function txt(column) {
|
|
215
215
|
return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return `CASE WHEN ${p}total_tokens > 0 AND ${p}cost_total = 0
|
|
221
|
-
AND (${p}provider = 'xai-oauth' OR ${p}cost_unpriced = 1) THEN 1 ELSE 0 END`;
|
|
217
|
+
/** The unpriced rule, kept for callers that group by it: no route here is a subscription route. */
|
|
218
|
+
function unpricedSql() {
|
|
219
|
+
return '0';
|
|
222
220
|
}
|
|
223
221
|
/** The additive facts every messages group reads. */
|
|
224
|
-
function messageFacts(
|
|
222
|
+
function messageFacts() {
|
|
225
223
|
return [
|
|
226
224
|
'COUNT(*)',
|
|
227
|
-
`SUM(CASE WHEN ${
|
|
228
|
-
`SUM(${
|
|
229
|
-
`SUM(${
|
|
230
|
-
`SUM(${
|
|
231
|
-
`SUM(${
|
|
232
|
-
`SUM(${
|
|
233
|
-
`TOTAL(${
|
|
234
|
-
`TOTAL(${
|
|
235
|
-
`TOTAL(${
|
|
236
|
-
`TOTAL(${
|
|
237
|
-
`SUM(${
|
|
238
|
-
`TOTAL(${
|
|
239
|
-
`COUNT(${
|
|
240
|
-
`TOTAL(${
|
|
241
|
-
`COUNT(${
|
|
242
|
-
`TOTAL(CASE WHEN ${
|
|
243
|
-
`COUNT(CASE WHEN ${
|
|
244
|
-
`MIN(${
|
|
245
|
-
`MAX(${
|
|
246
|
-
`MAX(${
|
|
225
|
+
`SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)`,
|
|
226
|
+
`SUM(${COL.input})`,
|
|
227
|
+
`SUM(${COL.output})`,
|
|
228
|
+
`SUM(${COL.cacheRead})`,
|
|
229
|
+
`SUM(${COL.cacheWrite})`,
|
|
230
|
+
`SUM(${COL.total})`,
|
|
231
|
+
`TOTAL(${COL.costTotal})`,
|
|
232
|
+
`TOTAL(${COL.costInput})`,
|
|
233
|
+
`TOTAL(${COL.costCacheRead})`,
|
|
234
|
+
`TOTAL(${COL.costCacheWrite})`,
|
|
235
|
+
`SUM(${COL.unpriced})`,
|
|
236
|
+
`TOTAL(${COL.duration})`,
|
|
237
|
+
`COUNT(${COL.duration})`,
|
|
238
|
+
`TOTAL(${COL.ttft})`,
|
|
239
|
+
`COUNT(${COL.ttft})`,
|
|
240
|
+
`TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END)`,
|
|
241
|
+
`COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END)`,
|
|
242
|
+
`MIN(${COL.timestamp})`,
|
|
243
|
+
`MAX(${COL.timestamp})`,
|
|
244
|
+
`MAX(${COL.duration})`,
|
|
247
245
|
].join(', ');
|
|
248
246
|
}
|
|
249
|
-
/**
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
['cost_cache_write', '0'],
|
|
256
|
-
['cost_unpriced', '0'],
|
|
257
|
-
['provider', "''"],
|
|
258
|
-
['folder', "''"],
|
|
259
|
-
['agent_type', "''"],
|
|
247
|
+
/** Column order of a recent or error row, matching `requestColumns` below. */
|
|
248
|
+
const REQUEST_COLUMNS = [
|
|
249
|
+
COL.timestamp, COL.provider, COL.model, COL.folder, COL.api,
|
|
250
|
+
COL.input, COL.output, COL.cacheRead, COL.cacheWrite, COL.total,
|
|
251
|
+
COL.costTotal, COL.unpriced, COL.duration, COL.ttft, COL.stopReason, COL.errorMessage,
|
|
252
|
+
COL.agentType, COL.session, COL.entry,
|
|
260
253
|
];
|
|
261
|
-
/** Applies the shims: a column the table does not hold reads as its literal. */
|
|
262
|
-
function withShims(sql, cols) {
|
|
263
|
-
let out = sql;
|
|
264
|
-
for (const [column, literal] of SHIMS) {
|
|
265
|
-
if (cols.has(column))
|
|
266
|
-
continue;
|
|
267
|
-
out = out.split(column).join(literal);
|
|
268
|
-
}
|
|
269
|
-
return out;
|
|
270
|
-
}
|
|
271
254
|
function parseFacts(row, at) {
|
|
272
255
|
const g = (i) => num(row?.[at + i]);
|
|
273
256
|
return {
|
|
@@ -332,7 +315,6 @@ function rangeWhere(cutoff, column = 'timestamp') {
|
|
|
332
315
|
function round4(v) {
|
|
333
316
|
return v === null ? null : Math.round(v * 1e4) / 1e4;
|
|
334
317
|
}
|
|
335
|
-
// ------------------------------------------------------------------- the aggregate
|
|
336
318
|
function bucketSize(range, firstTs, lastTs) {
|
|
337
319
|
const base = BUCKET_MS[range];
|
|
338
320
|
if (firstTs === null || lastTs === null)
|
|
@@ -342,38 +324,30 @@ function bucketSize(range, firstTs, lastTs) {
|
|
|
342
324
|
return base;
|
|
343
325
|
return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
|
|
344
326
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
const apiCol = cols.has('api') ? txt('api') : "''";
|
|
350
|
-
const errorWhere = `WHERE stop_reason = 'error'${cutoff > 0 ? ` AND timestamp >= ${Math.floor(cutoff)}` : ''}`;
|
|
351
|
-
const requestColumns = `timestamp, ${txt('provider')}, ${txt('model')}, ${txt('folder')}, ${apiCol},
|
|
352
|
-
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
|
|
353
|
-
cost_total, ${unpricedSql()}, duration, ttft, ${txt('stop_reason')}, ${errCol},
|
|
354
|
-
${txt('agent_type')}, ${txt('session_file')}, ${txt('entry_id')}`;
|
|
327
|
+
function readStatsDb(db, cutoff, bucketMs, parts, offset = 0) {
|
|
328
|
+
const where = rangeWhere(cutoff, COL.timestamp);
|
|
329
|
+
const errorWhere = `WHERE ${COL.stopReason} = 'error'${cutoff > 0 ? ` AND ${COL.timestamp} >= ${Math.floor(cutoff)}` : ''}`;
|
|
330
|
+
const from = SOURCE;
|
|
355
331
|
const out = readBlocks(db, [
|
|
356
|
-
['overall', `SELECT ${messageFacts()} FROM
|
|
357
|
-
['byModel', `SELECT ${
|
|
358
|
-
['byProvider', `SELECT ${
|
|
359
|
-
['byAgentType', `SELECT ${
|
|
360
|
-
['byProject', `SELECT ${
|
|
361
|
-
['series', `SELECT (
|
|
362
|
-
SUM(CASE WHEN
|
|
363
|
-
SUM(
|
|
364
|
-
['hourOfDay', `SELECT CAST(strftime('%H',
|
|
365
|
-
COUNT(*), SUM(CASE WHEN
|
|
366
|
-
FROM
|
|
367
|
-
['recent', `SELECT ${
|
|
368
|
-
['errors', `SELECT ${
|
|
369
|
-
['traces', `SELECT ${
|
|
370
|
-
COUNT(*), SUM(
|
|
371
|
-
MIN(timestamp), MAX(timestamp + COALESCE(duration, 0))
|
|
372
|
-
FROM
|
|
373
|
-
].filter(([name]) => parts.has(name)).map(([name, sql]) => [name,
|
|
374
|
-
//
|
|
375
|
-
// read across every transcript and keyed by root, so a child that fell outside the window still
|
|
376
|
-
// folds into the session it belongs to.
|
|
332
|
+
['overall', `SELECT ${messageFacts()} FROM ${from} ${where}`],
|
|
333
|
+
['byModel', `SELECT ${COL.model}, ${COL.provider}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 3 DESC`],
|
|
334
|
+
['byProvider', `SELECT ${COL.provider}, COUNT(DISTINCT ${COL.model}), ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 3 DESC`],
|
|
335
|
+
['byAgentType', `SELECT ${COL.agentType}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
336
|
+
['byProject', `SELECT ${COL.folder}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
337
|
+
['series', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, COUNT(*),
|
|
338
|
+
SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END),
|
|
339
|
+
SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
|
|
340
|
+
['hourOfDay', `SELECT CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
341
|
+
COUNT(*), SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END), SUM(${COL.total}), TOTAL(${COL.costTotal})
|
|
342
|
+
FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
|
|
343
|
+
['recent', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${where} ORDER BY ${COL.timestamp} DESC LIMIT ${RECENT_LIMIT} OFFSET ${Math.max(0, Math.floor(offset))}`],
|
|
344
|
+
['errors', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${errorWhere} ORDER BY ${COL.timestamp} DESC LIMIT ${ERROR_LIMIT}`],
|
|
345
|
+
['traces', `SELECT ${COL.session}, MAX(${COL.folder}),
|
|
346
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}), SUM(${COL.unpriced}),
|
|
347
|
+
MIN(${COL.timestamp}), MAX(${COL.timestamp} + COALESCE(${COL.duration}, 0))
|
|
348
|
+
FROM ${from} ${where} GROUP BY 1 ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
|
|
349
|
+
].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, sql]));
|
|
350
|
+
// Models and tools are keyed by root, so a child outside the window still folds into its session.
|
|
377
351
|
const traceModels = readTraceModels(db);
|
|
378
352
|
const traceTools = readTraceToolCounts(db);
|
|
379
353
|
const traces = foldTraces((out.get('traces') ?? []).map((r) => {
|
|
@@ -439,13 +413,7 @@ function readStatsDb(db, cutoff, bucketMs, cols, parts) {
|
|
|
439
413
|
traces: traces,
|
|
440
414
|
};
|
|
441
415
|
}
|
|
442
|
-
/**
|
|
443
|
-
* Fold every child transcript into its root session, the way the reference lists them.
|
|
444
|
-
*
|
|
445
|
-
* A subagent writes its own file under its root's folder, so a row grouped by file alone would
|
|
446
|
-
* show one row per subagent. Each root holds its children's requests, tokens, cost, models, and
|
|
447
|
-
* time, counts the children folded in, and keeps the row even when its own file is gone.
|
|
448
|
-
*/
|
|
416
|
+
/** Fold every child transcript into its root session, or a row per file would show one row per subagent. */
|
|
449
417
|
function foldTraces(rows) {
|
|
450
418
|
const byRoot = new Map();
|
|
451
419
|
for (const row of rows) {
|
|
@@ -528,9 +496,9 @@ function groupErrors(rows) {
|
|
|
528
496
|
.toSorted((a, b) => b.count - a.count || b.lastSeen - a.lastSeen);
|
|
529
497
|
}
|
|
530
498
|
function readProviderSeries(db, cutoff, bucketMs) {
|
|
531
|
-
const out = readBlocks(db, [['providerSeries', `SELECT ${
|
|
532
|
-
COUNT(*), SUM(
|
|
533
|
-
FROM
|
|
499
|
+
const out = readBlocks(db, [['providerSeries', `SELECT ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
500
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal})
|
|
501
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
534
502
|
const perProvider = new Map();
|
|
535
503
|
for (const r of out.get('providerSeries') ?? []) {
|
|
536
504
|
const provider = r[0] ?? '';
|
|
@@ -545,49 +513,44 @@ function readProviderSeries(db, cutoff, bucketMs) {
|
|
|
545
513
|
.slice(0, SERIES_PROVIDERS)
|
|
546
514
|
.map(([provider, held]) => ({ provider, points: held.points }));
|
|
547
515
|
}
|
|
516
|
+
/** Tool calls from each message's names array: a name is one whole call, so per-call sizes read as zero. */
|
|
548
517
|
function readToolSeries(db, cutoff, bucketMs) {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
${bucket},
|
|
556
|
-
${txt('t.tool_name')}, ${txt('t.model')}, ${txt('t.provider')},
|
|
518
|
+
const where = `WHERE ${COL.timestamp} >= ${Math.floor(cutoff)}`;
|
|
519
|
+
const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
|
|
520
|
+
const from = `${SOURCE}, json_each(${names}) te`;
|
|
521
|
+
const facts = `
|
|
522
|
+
${COL.timestamp},
|
|
523
|
+
te.value, ${COL.model}, ${COL.provider},
|
|
557
524
|
COUNT(*),
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn),
|
|
563
|
-
TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn),
|
|
564
|
-
TOTAL(CASE WHEN COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
|
|
565
|
-
AND (m.provider = 'xai-oauth' OR m.cost_unpriced = 1) THEN 1 ELSE 0 END * 1.0 / t.calls_in_turn),
|
|
566
|
-
MAX(t.timestamp)${extra}`;
|
|
525
|
+
TOTAL(${COL.total}),
|
|
526
|
+
TOTAL(${COL.output}),
|
|
527
|
+
TOTAL(${COL.costTotal}),
|
|
528
|
+
MAX(${COL.timestamp})`;
|
|
567
529
|
const out = readBlocks(db, [
|
|
568
|
-
['byTool', `SELECT ${facts
|
|
569
|
-
['byToolModel', `SELECT ${facts
|
|
570
|
-
|
|
571
|
-
['toolSeries', `SELECT (
|
|
572
|
-
SUM(CASE WHEN
|
|
530
|
+
['byTool', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value ORDER BY 5 DESC`],
|
|
531
|
+
['byToolModel', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value, ${COL.model}, ${COL.provider}
|
|
532
|
+
ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
|
|
533
|
+
['toolSeries', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, te.value, COUNT(*),
|
|
534
|
+
SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)
|
|
535
|
+
FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 1`],
|
|
573
536
|
]);
|
|
574
|
-
const parseTool = (r
|
|
537
|
+
const parseTool = (r) => ({
|
|
575
538
|
tool: r[1] ?? '',
|
|
576
539
|
calls: num(r[4]),
|
|
577
|
-
errors:
|
|
578
|
-
argsChars:
|
|
579
|
-
resultChars:
|
|
580
|
-
totalTokensShare: num(r[
|
|
581
|
-
outputTokensShare: num(r[
|
|
582
|
-
costShare: num(r[
|
|
583
|
-
unpricedShare:
|
|
584
|
-
lastUsed: numOrNull(r[
|
|
585
|
-
model: r[
|
|
586
|
-
provider: r[
|
|
540
|
+
errors: 0,
|
|
541
|
+
argsChars: 0,
|
|
542
|
+
resultChars: 0,
|
|
543
|
+
totalTokensShare: num(r[5]),
|
|
544
|
+
outputTokensShare: num(r[6]),
|
|
545
|
+
costShare: num(r[7]),
|
|
546
|
+
unpricedShare: 0,
|
|
547
|
+
lastUsed: numOrNull(r[8]),
|
|
548
|
+
model: r[2] ?? '',
|
|
549
|
+
provider: r[3] ?? '',
|
|
587
550
|
});
|
|
588
551
|
return {
|
|
589
|
-
tools: (out.get('byTool') ?? []).map(
|
|
590
|
-
toolsByModel: (out.get('byToolModel') ?? []).map(
|
|
552
|
+
tools: (out.get('byTool') ?? []).map(parseTool),
|
|
553
|
+
toolsByModel: (out.get('byToolModel') ?? []).map(parseTool),
|
|
591
554
|
toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
|
|
592
555
|
ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
|
|
593
556
|
})),
|
|
@@ -597,7 +560,8 @@ function readToolSeries(db, cutoff, bucketMs) {
|
|
|
597
560
|
function readTraceToolCounts(db) {
|
|
598
561
|
const counts = new Map();
|
|
599
562
|
try {
|
|
600
|
-
const
|
|
563
|
+
const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
|
|
564
|
+
const out = sqliteScript(db, `SELECT ${COL.session}, COUNT(*) FROM ${SOURCE}, json_each(${names}) te GROUP BY 1;\n`);
|
|
601
565
|
for (const line of out.split('\n')) {
|
|
602
566
|
if (!line.trim())
|
|
603
567
|
continue;
|
|
@@ -615,7 +579,7 @@ function readTraceToolCounts(db) {
|
|
|
615
579
|
function readTraceModels(db) {
|
|
616
580
|
const models = new Map();
|
|
617
581
|
try {
|
|
618
|
-
const out = sqliteScript(db,
|
|
582
|
+
const out = sqliteScript(db, `SELECT ${COL.session}, ${COL.model} FROM ${SOURCE} GROUP BY 1, 2;\n`);
|
|
619
583
|
for (const line of out.split('\n')) {
|
|
620
584
|
if (!line.trim())
|
|
621
585
|
continue;
|
|
@@ -630,12 +594,7 @@ function readTraceModels(db) {
|
|
|
630
594
|
catch { /* an unreadable table leaves the list empty */ }
|
|
631
595
|
return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
|
|
632
596
|
}
|
|
633
|
-
/**
|
|
634
|
-
* Each root session's own title and working directory, read from the head of its transcript.
|
|
635
|
-
*
|
|
636
|
-
* The `session` line carries the working directory and the `title` line sits above it, so one head
|
|
637
|
-
* read answers both. A rotated or unreadable file leaves the row's folder and file name to speak.
|
|
638
|
-
*/
|
|
597
|
+
/** A session's title and cwd: the `session` line carries the directory and the `title` line sits above it. */
|
|
639
598
|
function applySessionMetadata(traces) {
|
|
640
599
|
for (const trace of traces) {
|
|
641
600
|
if (!trace.sessionFile.endsWith('.jsonl'))
|
|
@@ -671,11 +630,11 @@ function applySessionMetadata(traces) {
|
|
|
671
630
|
}
|
|
672
631
|
/** Daily tokens and cost for the busiest models: the Models table sparkline. */
|
|
673
632
|
function readModelSeries(db, cutoff, bucketMs) {
|
|
674
|
-
const scope = rangeWhere(cutoff);
|
|
675
|
-
const top =
|
|
633
|
+
const scope = rangeWhere(cutoff, COL.timestamp);
|
|
634
|
+
const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
676
635
|
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
677
|
-
const out = readBlocks(db, [['modelSeries', `SELECT ${
|
|
678
|
-
COUNT(*), SUM(
|
|
636
|
+
const out = readBlocks(db, [['modelSeries', `SELECT ${COL.model}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
637
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${SOURCE} ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
679
638
|
const perModel = new Map();
|
|
680
639
|
for (const r of out.get('modelSeries') ?? []) {
|
|
681
640
|
const model = r[0] ?? '';
|
|
@@ -685,22 +644,18 @@ function readModelSeries(db, cutoff, bucketMs) {
|
|
|
685
644
|
}
|
|
686
645
|
return [...perModel.entries()].map(([model, points]) => ({ model, points }));
|
|
687
646
|
}
|
|
688
|
-
/**
|
|
689
|
-
* Throughput and first-token latency per bucket for the busiest models, the chart behind a
|
|
690
|
-
* model's expanded row. Both averages are null in a bucket where no request carried the timing,
|
|
691
|
-
* because a non-null zero would draw a flat line through a real gap.
|
|
692
|
-
*/
|
|
647
|
+
/** Per-bucket throughput and ttft for the busiest models; a null average means no request carried timing. */
|
|
693
648
|
function readModelPerformance(db, cutoff, bucketMs) {
|
|
694
|
-
const scope = rangeWhere(cutoff);
|
|
695
|
-
const top =
|
|
649
|
+
const scope = rangeWhere(cutoff, COL.timestamp);
|
|
650
|
+
const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
696
651
|
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
697
|
-
const out = readBlocks(db, [['modelPerformance', `SELECT ${
|
|
652
|
+
const out = readBlocks(db, [['modelPerformance', `SELECT ${COL.model}, ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
698
653
|
COUNT(*),
|
|
699
|
-
TOTAL(CASE WHEN duration > 0 THEN
|
|
700
|
-
COUNT(CASE WHEN duration > 0 THEN 1 END),
|
|
701
|
-
TOTAL(ttft),
|
|
702
|
-
COUNT(ttft)
|
|
703
|
-
FROM
|
|
654
|
+
TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END),
|
|
655
|
+
COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END),
|
|
656
|
+
TOTAL(${COL.ttft}),
|
|
657
|
+
COUNT(${COL.ttft})
|
|
658
|
+
FROM ${SOURCE} ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
|
|
704
659
|
const groups = new Map();
|
|
705
660
|
for (const r of out.get('modelPerformance') ?? []) {
|
|
706
661
|
const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
|
|
@@ -717,7 +672,6 @@ function readModelPerformance(db, cutoff, bucketMs) {
|
|
|
717
672
|
}
|
|
718
673
|
return [...groups.values()];
|
|
719
674
|
}
|
|
720
|
-
// ------------------------------------------------------------ subscription windows
|
|
721
675
|
/** Reference constants for the quota half, copied from the omp implementation. */
|
|
722
676
|
const WINDOW_RESET_DROP = 0.05;
|
|
723
677
|
const WINDOW_MIN_EXTRAPOLATION = 0.1;
|
|
@@ -746,10 +700,7 @@ function downsampleWindow(points) {
|
|
|
746
700
|
}
|
|
747
701
|
return out;
|
|
748
702
|
}
|
|
749
|
-
/**
|
|
750
|
-
* Peak of the sum across accounts at any sampled instant, forward filling each account's last
|
|
751
|
-
* known fraction. A peak of 1.7 means demand simultaneously held 1.7 windows of quota.
|
|
752
|
-
*/
|
|
703
|
+
/** Peak summed quota across accounts, forward-filling last known fractions (1.7 = 1.7 windows held). */
|
|
753
704
|
function peakConcurrent(samples) {
|
|
754
705
|
const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
|
|
755
706
|
const current = new Map();
|
|
@@ -795,10 +746,7 @@ function readWindowSamples(cutoff) {
|
|
|
795
746
|
};
|
|
796
747
|
});
|
|
797
748
|
}
|
|
798
|
-
/**
|
|
799
|
-
* Utilization series and per-window insights. A window is one `(provider, limitId)` pair: distinct
|
|
800
|
-
* limits can share a duration label, and merging them interleaves unrelated fractions per account.
|
|
801
|
-
*/
|
|
749
|
+
/** Window utilization; a window is one `(provider, limitId)` pair, since limits can share a duration. */
|
|
802
750
|
function computeWindows(samples, tokensByProvider) {
|
|
803
751
|
const groups = new Map();
|
|
804
752
|
for (const sample of samples) {
|
|
@@ -881,16 +829,16 @@ function computeWindows(samples, tokensByProvider) {
|
|
|
881
829
|
}
|
|
882
830
|
/** Tokens per provider over the range: the currency a window insight extrapolates in. */
|
|
883
831
|
function readProviderTokens(db, cutoff) {
|
|
884
|
-
const out = readBlocks(db, [['providerTokens', `SELECT ${
|
|
885
|
-
FROM
|
|
832
|
+
const out = readBlocks(db, [['providerTokens', `SELECT ${COL.provider}, SUM(${COL.total})
|
|
833
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1`]]);
|
|
886
834
|
return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
|
|
887
835
|
}
|
|
888
836
|
/** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
|
|
889
837
|
function readProviderHourly(db, cutoff) {
|
|
890
|
-
const out = readBlocks(db, [['providerHourly', `SELECT ${
|
|
891
|
-
CAST(strftime('%H',
|
|
892
|
-
SUM(
|
|
893
|
-
FROM
|
|
838
|
+
const out = readBlocks(db, [['providerHourly', `SELECT ${COL.provider},
|
|
839
|
+
CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
840
|
+
SUM(${COL.total}), SUM(${COL.output}), COUNT(*)
|
|
841
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2`]]);
|
|
894
842
|
return (out.get('providerHourly') ?? []).map((r) => ({
|
|
895
843
|
provider: r[0] ?? '',
|
|
896
844
|
hour: num(r[1]),
|
|
@@ -907,24 +855,25 @@ function median(values) {
|
|
|
907
855
|
const mid = nums.length >> 1;
|
|
908
856
|
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
|
|
909
857
|
}
|
|
910
|
-
/**
|
|
911
|
-
function computeRequestStats(rows) {
|
|
858
|
+
/** Requests summary; percentiles stay over the capped slice, so a p95 of 200 of 2,400 describes those 200. */
|
|
859
|
+
function computeRequestStats(rows, range) {
|
|
912
860
|
const durations = rows.map((row) => row.durationMs);
|
|
913
861
|
const sorted = durations.filter((v) => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
|
|
914
862
|
const pick = (q) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
|
|
915
863
|
const times = rows.map((row) => row.ts);
|
|
916
864
|
return {
|
|
917
|
-
requests: rows.length,
|
|
918
|
-
failed: rows.filter((row) => row.stopReason === 'error').length,
|
|
865
|
+
requests: range ? range.requests : rows.length,
|
|
866
|
+
failed: range ? range.failed : rows.filter((row) => row.stopReason === 'error').length,
|
|
919
867
|
aborted: rows.filter((row) => row.stopReason === 'aborted').length,
|
|
920
|
-
tokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
921
|
-
costUsd: rows.reduce((sum, row) => sum + row.costUsd, 0),
|
|
922
|
-
unpriced: rows.filter((row) => row.unpriced).length,
|
|
868
|
+
tokens: range ? range.total : rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
869
|
+
costUsd: range ? range.costUsd : rows.reduce((sum, row) => sum + row.costUsd, 0),
|
|
870
|
+
unpriced: range ? range.unpricedRequests : rows.filter((row) => row.unpriced).length,
|
|
871
|
+
shipped: rows.length,
|
|
923
872
|
medianDurationMs: median(durations),
|
|
924
873
|
p95DurationMs: pick(0.95),
|
|
925
874
|
medianTtftMs: median(rows.map((row) => row.ttftMs)),
|
|
926
|
-
oldest: times.length ? Math.min(...times) : null,
|
|
927
|
-
newest: times.length ? Math.max(...times) : null,
|
|
875
|
+
oldest: range ? range.firstTs : times.length ? Math.min(...times) : null,
|
|
876
|
+
newest: range ? range.lastTs : times.length ? Math.max(...times) : null,
|
|
928
877
|
};
|
|
929
878
|
}
|
|
930
879
|
/** How many spans one trace ships. A long session holds thousands; the transcript virtualises. */
|
|
@@ -949,15 +898,7 @@ function stopState(stopReason) {
|
|
|
949
898
|
}
|
|
950
899
|
/** Tool names that hand the work to another agent, for the Agents figure. */
|
|
951
900
|
const AGENT_TOOLS = new Set(['task', 'subagent']);
|
|
952
|
-
/**
|
|
953
|
-
* One session transcript as spans, tracks, markers, and totals.
|
|
954
|
-
*
|
|
955
|
-
* An assistant message stamps its completion and its `duration` says when the call began, so a
|
|
956
|
-
* model span runs `ts - duration` to `ts`. A tool call is emitted by the message requesting it and
|
|
957
|
-
* closed by the `toolResult` with the same call id, so the two bound a tool span. A user message
|
|
958
|
-
* opens a turn the next user message closes, and everything that is not a message rides along as a
|
|
959
|
-
* marker, since it happened at a point in time rather than across one.
|
|
960
|
-
*/
|
|
901
|
+
/** One transcript as spans, tracks and markers: a model span runs `ts - duration` to `ts`. */
|
|
961
902
|
export function readSessionTrace(sessionFile, limit = TRACE_SPAN_LIMIT) {
|
|
962
903
|
const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
|
|
963
904
|
const empty = {
|
|
@@ -1174,12 +1115,7 @@ function buildToolStats(spans) {
|
|
|
1174
1115
|
export function emptyRequestEntry() {
|
|
1175
1116
|
return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
|
|
1176
1117
|
}
|
|
1177
|
-
/**
|
|
1178
|
-
* One request's journal payload, read on demand by the request drawer. `entry` is the raw
|
|
1179
|
-
* journal line, `output` is the message that line carries, and `agent` is the agent state in
|
|
1180
|
-
* force when the request ran. All are empty when the entry journals nothing: a `model_usage`
|
|
1181
|
-
* row carries no message, and a deleted session cannot be re-read.
|
|
1182
|
-
*/
|
|
1118
|
+
/** One request's journal payload; empty when the entry journals nothing or its session is gone. */
|
|
1183
1119
|
export function readAgentRequestEntry(sessionFile, entryId) {
|
|
1184
1120
|
const empty = emptyRequestEntry();
|
|
1185
1121
|
if (!entryId || !sessionFile.endsWith('.jsonl') || !fs.existsSync(sessionFile))
|
|
@@ -1221,8 +1157,8 @@ export function readAgentRequestEntry(sessionFile, entryId) {
|
|
|
1221
1157
|
}
|
|
1222
1158
|
return empty;
|
|
1223
1159
|
}
|
|
1224
|
-
/** Reads
|
|
1225
|
-
export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
|
|
1160
|
+
/** Reads the usage store; the caller runs `syncUsageDb`, so a poll never pays for a sync it did not start. */
|
|
1161
|
+
export function readAgentStats(range = '24h', view = 'all', now = Date.now(), offset = 0) {
|
|
1226
1162
|
const span = RANGE_MS[range];
|
|
1227
1163
|
const cutoff = span > 0 ? now - span : 0;
|
|
1228
1164
|
const parts = new Set(VIEW_PARTS[view]);
|
|
@@ -1236,10 +1172,10 @@ export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
|
|
|
1236
1172
|
return stats;
|
|
1237
1173
|
}
|
|
1238
1174
|
// The bucket width depends on the span, so the bounds come first.
|
|
1239
|
-
const bounds = readBlocks(db, [['bounds', `SELECT MIN(timestamp), MAX(timestamp) FROM
|
|
1175
|
+
const bounds = readBlocks(db, [['bounds', `SELECT MIN(${COL.timestamp}), MAX(${COL.timestamp}) FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)}`]]);
|
|
1240
1176
|
const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
|
|
1241
1177
|
stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
|
|
1242
|
-
Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs,
|
|
1178
|
+
Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, parts, offset));
|
|
1243
1179
|
if (parts.has('seriesByProvider'))
|
|
1244
1180
|
stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
|
|
1245
1181
|
if (parts.has('modelSeries'))
|
|
@@ -1253,7 +1189,7 @@ export function readAgentStats(range = '24h', view = 'all', now = Date.now()) {
|
|
|
1253
1189
|
if (parts.has('providerHourly'))
|
|
1254
1190
|
stats.providerHourly = readProviderHourly(db, cutoff);
|
|
1255
1191
|
if (parts.has('recent'))
|
|
1256
|
-
stats.requestStats = computeRequestStats(stats.recent);
|
|
1192
|
+
stats.requestStats = computeRequestStats(stats.recent, stats.overall);
|
|
1257
1193
|
if (parts.has('windows')) {
|
|
1258
1194
|
Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
|
|
1259
1195
|
}
|