@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.ts';
|
|
6
|
+
import { usageDbPath } from './usage-store.ts';
|
|
10
7
|
|
|
11
8
|
export type RangeKey = '1h' | '24h' | '7d' | '30d' | '90d' | 'all';
|
|
12
9
|
|
|
@@ -42,7 +39,7 @@ const VIEW_PARTS: Record<AgentView, readonly string[]> = {
|
|
|
42
39
|
costs: ['overall', 'byModel', 'series', 'modelSeries'],
|
|
43
40
|
// The Carbon page needs the same two facts as Costs: per-model output tokens and the mix.
|
|
44
41
|
carbon: ['overall', 'byModel'],
|
|
45
|
-
requests: ['recent'],
|
|
42
|
+
requests: ['overall', 'recent'],
|
|
46
43
|
errors: ['errors', 'overall', 'byModel', 'recent'],
|
|
47
44
|
traces: ['traces'],
|
|
48
45
|
tools: ['overall', 'tools'],
|
|
@@ -79,14 +76,91 @@ const TOOL_MODEL_LIMIT = 300;
|
|
|
79
76
|
const SERIES_PROVIDERS = 8;
|
|
80
77
|
const SERIES_MODELS = 12;
|
|
81
78
|
|
|
79
|
+
/** The store every metric but subscription quota reads, kept in step by `tersio usage`. */
|
|
82
80
|
export function statsDbPath(): string {
|
|
83
|
-
return process.env.TERSIO_AGENT_STATS_DB ??
|
|
81
|
+
return process.env.TERSIO_AGENT_STATS_DB ?? usageDbPath();
|
|
84
82
|
}
|
|
85
83
|
|
|
84
|
+
/** Quota snapshots. Only omp records these, so this stays omp's own database. */
|
|
86
85
|
export function agentDbPath(): string {
|
|
87
86
|
return process.env.TERSIO_AGENT_SESSIONS_DB ?? path.join(os.homedir(), '.omp', 'agent', 'agent.db');
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
/** The row shape every query groups by: a message joined to its file, since path and facts live apart. */
|
|
90
|
+
const SOURCE = 'messages m JOIN files f ON f.id = m.file_id';
|
|
91
|
+
|
|
92
|
+
/** The transcript path with any host-side row suffix removed, so one file groups as one session. */
|
|
93
|
+
const FILE_KEY = `substr(f.path, 1, instr(f.path || '#', '#') - 1)`;
|
|
94
|
+
|
|
95
|
+
/** Model-id spellings whose dashboard mark lives under another name; the first segment names the gateway. */
|
|
96
|
+
const PROVIDER_ALIASES: Readonly<Record<string, string>> = {
|
|
97
|
+
'opencode-zen': 'opencode',
|
|
98
|
+
'opencode-go': 'opencode',
|
|
99
|
+
'gmi-cloud': 'gmicloud',
|
|
100
|
+
'charm-hyper': 'charmhyper',
|
|
101
|
+
'kimi-code': 'kimi',
|
|
102
|
+
'openai-codex': 'openai',
|
|
103
|
+
'github-copilot': 'githubcopilot',
|
|
104
|
+
'ollama-cloud': 'ollama',
|
|
105
|
+
'amd-radeon-cloud-cn': 'amd',
|
|
106
|
+
'google-antigravity': 'google',
|
|
107
|
+
devin: 'cognition',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** SQL: the gateway a model id names, from its first segment. Empty when the id carries none, not a guess. */
|
|
111
|
+
function providerExpr(model: string): string {
|
|
112
|
+
const seg = `substr(${model}, 1, instr(${model} || '/', '/') - 1)`;
|
|
113
|
+
const named = `ltrim(${seg}, '~')`;
|
|
114
|
+
const cases = Object.entries(PROVIDER_ALIASES)
|
|
115
|
+
.map(([from, to]) => `WHEN '${from}' THEN '${to}'`)
|
|
116
|
+
.join(' ');
|
|
117
|
+
return `CASE WHEN instr(${model}, '/') = 0 THEN '' ELSE CASE ${named} ${cases} ELSE ${named} END END`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** SQL: the project a transcript belongs to, named by its session folder. A flat session tree uses its data dir. */
|
|
121
|
+
function projectExpr(file: string): string {
|
|
122
|
+
const marker = `instr(${file}, '/sessions/')`;
|
|
123
|
+
const seg = `substr(${file}, ${marker} + 10, instr(substr(${file}, ${marker} + 10) || '/', '/') - 1)`;
|
|
124
|
+
const base = `replace(${file}, rtrim(${file}, replace(${file}, '/', '')), '')`;
|
|
125
|
+
const dir = `substr(${file}, 1, length(${file}) - length(${base}) - 1)`;
|
|
126
|
+
const dirBase = `replace(${dir}, rtrim(${dir}, replace(${dir}, '/', '')), '')`;
|
|
127
|
+
return `CASE WHEN ${marker} > 0 THEN ${seg} ELSE ${dirBase} END`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every column a query reads, mapped to the store's schema in one place. */
|
|
131
|
+
const COL = {
|
|
132
|
+
session: FILE_KEY,
|
|
133
|
+
entry: `COALESCE(m.id, '')`,
|
|
134
|
+
folder: projectExpr(FILE_KEY),
|
|
135
|
+
model: 'm.model',
|
|
136
|
+
provider: providerExpr('m.model'),
|
|
137
|
+
/** No route name is stored, and the Requests page reads one. */
|
|
138
|
+
api: "''",
|
|
139
|
+
timestamp: 'm.t',
|
|
140
|
+
// Bucketing form of the timestamp. REAL division does not floor, so the cast is needed.
|
|
141
|
+
bucket: 'CAST(COALESCE(m.t, 0) AS INTEGER)',
|
|
142
|
+
duration: 'm.d',
|
|
143
|
+
/** First-token latency. A host that records none leaves it NULL, which reads null, never zero. */
|
|
144
|
+
ttft: 'm.tf',
|
|
145
|
+
stopReason: `CASE m.st WHEN 'error' THEN 'error' WHEN 'aborted' THEN 'aborted' ELSE 'stop' END`,
|
|
146
|
+
errorMessage: 'm.note',
|
|
147
|
+
input: 'm.i',
|
|
148
|
+
output: 'm.o',
|
|
149
|
+
cacheRead: 'm.cr',
|
|
150
|
+
cacheWrite: 'm.cw',
|
|
151
|
+
total: '(m.i + m.o + m.cr + m.cw)',
|
|
152
|
+
/** The cost the host reported. A model the host priced nothing for reads $0.00 by design. */
|
|
153
|
+
costTotal: 'COALESCE(m.usd, 0)',
|
|
154
|
+
agentType: `COALESCE(m.a, 'main')`,
|
|
155
|
+
host: `COALESCE(m.h, '')`,
|
|
156
|
+
/** The store holds no per-component split, so the remainder rule below finds nothing to move. */
|
|
157
|
+
costInput: '0',
|
|
158
|
+
costCacheRead: '0',
|
|
159
|
+
costCacheWrite: '0',
|
|
160
|
+
/** No route is a subscription route here, so nothing is ever flagged unpriced. */
|
|
161
|
+
unpriced: '0',
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
90
164
|
export interface AgentTokenMix {
|
|
91
165
|
input: number;
|
|
92
166
|
output: number;
|
|
@@ -140,7 +214,6 @@ export interface AgentRow extends AgentTokenMix {
|
|
|
140
214
|
key: string;
|
|
141
215
|
/** The provider this row belongs to, empty when the group is not per provider. */
|
|
142
216
|
provider: string;
|
|
143
|
-
/** The row's estimate split by billing component. */
|
|
144
217
|
costMix: AgentCostMix;
|
|
145
218
|
requests: number;
|
|
146
219
|
failed: number;
|
|
@@ -268,7 +341,7 @@ export interface AgentWindowPoint {
|
|
|
268
341
|
exhausted: boolean;
|
|
269
342
|
}
|
|
270
343
|
|
|
271
|
-
/** The Requests page
|
|
344
|
+
/** The Requests page summary row: whole-range counts and totals; `shipped` counts rows that rode along. */
|
|
272
345
|
export interface AgentRequestStats {
|
|
273
346
|
requests: number;
|
|
274
347
|
failed: number;
|
|
@@ -276,6 +349,8 @@ export interface AgentRequestStats {
|
|
|
276
349
|
tokens: number;
|
|
277
350
|
costUsd: number;
|
|
278
351
|
unpriced: number;
|
|
352
|
+
/** Rows that rode along with this payload, at most `RECENT_LIMIT`. */
|
|
353
|
+
shipped: number;
|
|
279
354
|
medianDurationMs: number;
|
|
280
355
|
p95DurationMs: number;
|
|
281
356
|
medianTtftMs: number;
|
|
@@ -453,89 +528,29 @@ export function emptyAgentStats(range: RangeKey = '24h'): AgentStats {
|
|
|
453
528
|
tools: [], toolsByModel: [], toolSeries: [],
|
|
454
529
|
usageSeries: [], windowInsights: [], providerHourly: [],
|
|
455
530
|
requestStats: {
|
|
456
|
-
requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0,
|
|
531
|
+
requests: 0, failed: 0, aborted: 0, tokens: 0, costUsd: 0, unpriced: 0, shipped: 0,
|
|
457
532
|
medianDurationMs: 0, p95DurationMs: 0, medianTtftMs: 0, oldest: null, newest: null,
|
|
458
533
|
},
|
|
459
534
|
errors: [],
|
|
460
535
|
};
|
|
461
536
|
}
|
|
462
537
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* The omp databases run in WAL mode. Ordered fallbacks, each verified against the live pair:
|
|
467
|
-
* `-readonly` needs the `-shm` side file, `immutable=1` silently ignores a WAL, and a copy of
|
|
468
|
-
* db + wal + shm replays the log to the same row count the running server reports.
|
|
469
|
-
*/
|
|
538
|
+
/** Rows from a read-only script, tab-separated: whichever engine this host has answers it. */
|
|
470
539
|
export function sqliteScript(db: string, script: string): string {
|
|
471
|
-
|
|
472
|
-
const opts = {
|
|
473
|
-
encoding: 'utf8' as const,
|
|
474
|
-
timeout: 30_000,
|
|
475
|
-
stdio: ['pipe', 'pipe', 'ignore'] as ['pipe', 'pipe', 'ignore'],
|
|
476
|
-
maxBuffer: SQLITE_READ_BUFFER,
|
|
477
|
-
input: script,
|
|
478
|
-
};
|
|
479
|
-
try {
|
|
480
|
-
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
|
|
481
|
-
} catch { /* the side files may be gone; try the next rung */ }
|
|
482
|
-
if (!fs.existsSync(`${db}-wal`)) {
|
|
483
|
-
try {
|
|
484
|
-
return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
|
|
485
|
-
} catch { /* fall through to the copy */ }
|
|
486
|
-
}
|
|
487
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-omp-'));
|
|
488
|
-
const target = path.join(dir, path.basename(db));
|
|
489
|
-
try {
|
|
490
|
-
fs.copyFileSync(db, target);
|
|
491
|
-
for (const suffix of ['-wal', '-shm']) {
|
|
492
|
-
if (fs.existsSync(`${db}${suffix}`)) fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
|
|
493
|
-
}
|
|
494
|
-
return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
|
|
495
|
-
} finally {
|
|
496
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
497
|
-
}
|
|
540
|
+
return sqliteRead(db, script);
|
|
498
541
|
}
|
|
499
542
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
function blocks(out: string): Map<string, string[][]> {
|
|
543
|
+
/** One call per query: an unavailable table leaves its key absent, so a partial schema still reads. */
|
|
544
|
+
function readBlocks(db: string, queries: Array<[name: string, sql: string]>): Map<string, string[][]> {
|
|
503
545
|
const map = new Map<string, string[][]>();
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
current = [];
|
|
509
|
-
map.set(line.slice(BLOCK.length), current);
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
if (current) current.push(line.split('\t'));
|
|
546
|
+
for (const [name, sql] of queries) {
|
|
547
|
+
try {
|
|
548
|
+
map.set(name, sqliteScript(db, sql).split('\n').filter((line) => line.trim()).map((line) => line.split('\t')));
|
|
549
|
+
} catch { /* an unavailable table leaves its key absent */ }
|
|
513
550
|
}
|
|
514
551
|
return map;
|
|
515
552
|
}
|
|
516
553
|
|
|
517
|
-
/**
|
|
518
|
-
* One sqlite3 call for a group of queries. A failing statement aborts the script, so the
|
|
519
|
-
* partial output is kept and only the missing blocks re-run one at a time.
|
|
520
|
-
*/
|
|
521
|
-
function readBlocks(db: string, queries: Array<[name: string, sql: string]>): Map<string, string[][]> {
|
|
522
|
-
const script = queries.map(([name, sql]) => `SELECT '${BLOCK}${name}';\n${sql};\n`).join('');
|
|
523
|
-
try {
|
|
524
|
-
return blocks(sqliteScript(db, script));
|
|
525
|
-
} catch (error) {
|
|
526
|
-
// A failing statement aborts the script, so its stdout holds every block that ran.
|
|
527
|
-
if (!error || typeof error !== 'object' || !('stdout' in error) || typeof error.stdout !== 'string') throw error;
|
|
528
|
-
const merged = blocks(error.stdout);
|
|
529
|
-
for (const [name, sql] of queries) {
|
|
530
|
-
if (merged.has(name)) continue;
|
|
531
|
-
try {
|
|
532
|
-
for (const [k, rows] of blocks(sqliteScript(db, `SELECT '${BLOCK}${name}';\n${sql};\n`))) merged.set(k, rows);
|
|
533
|
-
} catch { /* an unavailable table leaves its key absent */ }
|
|
534
|
-
}
|
|
535
|
-
return merged;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
554
|
function tableColumns(db: string, table: string): Set<string> {
|
|
540
555
|
try {
|
|
541
556
|
const out = sqliteScript(db, `SELECT name FROM pragma_table_info('${table}');\n`);
|
|
@@ -561,63 +576,45 @@ function txt(column: string): string {
|
|
|
561
576
|
return `replace(replace(replace(COALESCE(${column}, ''), char(10), ' '), char(13), ' '), char(9), ' ')`;
|
|
562
577
|
}
|
|
563
578
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
function unpricedSql(p = ''): string {
|
|
568
|
-
return `CASE WHEN ${p}total_tokens > 0 AND ${p}cost_total = 0
|
|
569
|
-
AND (${p}provider = 'xai-oauth' OR ${p}cost_unpriced = 1) THEN 1 ELSE 0 END`;
|
|
579
|
+
/** The unpriced rule, kept for callers that group by it: no route here is a subscription route. */
|
|
580
|
+
function unpricedSql(): string {
|
|
581
|
+
return '0';
|
|
570
582
|
}
|
|
571
583
|
|
|
572
584
|
/** The additive facts every messages group reads. */
|
|
573
|
-
function messageFacts(
|
|
585
|
+
function messageFacts(): string {
|
|
574
586
|
return [
|
|
575
587
|
'COUNT(*)',
|
|
576
|
-
`SUM(CASE WHEN ${
|
|
577
|
-
`SUM(${
|
|
578
|
-
`SUM(${
|
|
579
|
-
`SUM(${
|
|
580
|
-
`SUM(${
|
|
581
|
-
`SUM(${
|
|
582
|
-
`TOTAL(${
|
|
583
|
-
`TOTAL(${
|
|
584
|
-
`TOTAL(${
|
|
585
|
-
`TOTAL(${
|
|
586
|
-
`SUM(${
|
|
587
|
-
`TOTAL(${
|
|
588
|
-
`COUNT(${
|
|
589
|
-
`TOTAL(${
|
|
590
|
-
`COUNT(${
|
|
591
|
-
`TOTAL(CASE WHEN ${
|
|
592
|
-
`COUNT(CASE WHEN ${
|
|
593
|
-
`MIN(${
|
|
594
|
-
`MAX(${
|
|
595
|
-
`MAX(${
|
|
588
|
+
`SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)`,
|
|
589
|
+
`SUM(${COL.input})`,
|
|
590
|
+
`SUM(${COL.output})`,
|
|
591
|
+
`SUM(${COL.cacheRead})`,
|
|
592
|
+
`SUM(${COL.cacheWrite})`,
|
|
593
|
+
`SUM(${COL.total})`,
|
|
594
|
+
`TOTAL(${COL.costTotal})`,
|
|
595
|
+
`TOTAL(${COL.costInput})`,
|
|
596
|
+
`TOTAL(${COL.costCacheRead})`,
|
|
597
|
+
`TOTAL(${COL.costCacheWrite})`,
|
|
598
|
+
`SUM(${COL.unpriced})`,
|
|
599
|
+
`TOTAL(${COL.duration})`,
|
|
600
|
+
`COUNT(${COL.duration})`,
|
|
601
|
+
`TOTAL(${COL.ttft})`,
|
|
602
|
+
`COUNT(${COL.ttft})`,
|
|
603
|
+
`TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END)`,
|
|
604
|
+
`COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END)`,
|
|
605
|
+
`MIN(${COL.timestamp})`,
|
|
606
|
+
`MAX(${COL.timestamp})`,
|
|
607
|
+
`MAX(${COL.duration})`,
|
|
596
608
|
].join(', ');
|
|
597
609
|
}
|
|
598
610
|
|
|
599
|
-
/**
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
['cost_unpriced', '0'],
|
|
607
|
-
['provider', "''"],
|
|
608
|
-
['folder', "''"],
|
|
609
|
-
['agent_type', "''"],
|
|
610
|
-
];
|
|
611
|
-
|
|
612
|
-
/** Applies the shims: a column the table does not hold reads as its literal. */
|
|
613
|
-
function withShims(sql: string, cols: Set<string>): string {
|
|
614
|
-
let out = sql;
|
|
615
|
-
for (const [column, literal] of SHIMS) {
|
|
616
|
-
if (cols.has(column)) continue;
|
|
617
|
-
out = out.split(column).join(literal);
|
|
618
|
-
}
|
|
619
|
-
return out;
|
|
620
|
-
}
|
|
611
|
+
/** Column order of a recent or error row, matching `requestColumns` below. */
|
|
612
|
+
const REQUEST_COLUMNS = [
|
|
613
|
+
COL.timestamp, COL.provider, COL.model, COL.folder, COL.api,
|
|
614
|
+
COL.input, COL.output, COL.cacheRead, COL.cacheWrite, COL.total,
|
|
615
|
+
COL.costTotal, COL.unpriced, COL.duration, COL.ttft, COL.stopReason, COL.errorMessage,
|
|
616
|
+
COL.agentType, COL.session, COL.entry,
|
|
617
|
+
] as const;
|
|
621
618
|
|
|
622
619
|
interface Facts {
|
|
623
620
|
requests: number; failed: number;
|
|
@@ -702,8 +699,6 @@ function round4(v: number | null): number | null {
|
|
|
702
699
|
return v === null ? null : Math.round(v * 1e4) / 1e4;
|
|
703
700
|
}
|
|
704
701
|
|
|
705
|
-
// ------------------------------------------------------------------- the aggregate
|
|
706
|
-
|
|
707
702
|
function bucketSize(range: RangeKey, firstTs: number | null, lastTs: number | null): number {
|
|
708
703
|
const base = BUCKET_MS[range];
|
|
709
704
|
if (firstTs === null || lastTs === null) return base;
|
|
@@ -712,42 +707,32 @@ function bucketSize(range: RangeKey, firstTs: number | null, lastTs: number | nu
|
|
|
712
707
|
return Math.max(base, Math.ceil(span / MAX_BUCKETS / base) * base);
|
|
713
708
|
}
|
|
714
709
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
const errCol = cols.has('error_message') ? txt('error_message') : "''";
|
|
720
|
-
const apiCol = cols.has('api') ? txt('api') : "''";
|
|
721
|
-
const errorWhere = `WHERE stop_reason = 'error'${cutoff > 0 ? ` AND timestamp >= ${Math.floor(cutoff)}` : ''}`;
|
|
722
|
-
|
|
723
|
-
const requestColumns = `timestamp, ${txt('provider')}, ${txt('model')}, ${txt('folder')}, ${apiCol},
|
|
724
|
-
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
|
|
725
|
-
cost_total, ${unpricedSql()}, duration, ttft, ${txt('stop_reason')}, ${errCol},
|
|
726
|
-
${txt('agent_type')}, ${txt('session_file')}, ${txt('entry_id')}`;
|
|
710
|
+
function readStatsDb(db: string, cutoff: number, bucketMs: number, parts: Set<string>, offset = 0): Partial<AgentStats> {
|
|
711
|
+
const where = rangeWhere(cutoff, COL.timestamp);
|
|
712
|
+
const errorWhere = `WHERE ${COL.stopReason} = 'error'${cutoff > 0 ? ` AND ${COL.timestamp} >= ${Math.floor(cutoff)}` : ''}`;
|
|
713
|
+
const from = SOURCE;
|
|
727
714
|
|
|
728
715
|
const out = readBlocks(db, [
|
|
729
|
-
['overall', `SELECT ${messageFacts()} FROM
|
|
730
|
-
['byModel', `SELECT ${
|
|
731
|
-
['byProvider', `SELECT ${
|
|
732
|
-
['byAgentType', `SELECT ${
|
|
733
|
-
['byProject', `SELECT ${
|
|
734
|
-
['series', `SELECT (
|
|
735
|
-
SUM(CASE WHEN
|
|
736
|
-
SUM(
|
|
737
|
-
['hourOfDay', `SELECT CAST(strftime('%H',
|
|
738
|
-
COUNT(*), SUM(CASE WHEN
|
|
739
|
-
FROM
|
|
740
|
-
['recent', `SELECT ${
|
|
741
|
-
['errors', `SELECT ${
|
|
742
|
-
['traces', `SELECT ${
|
|
743
|
-
COUNT(*), SUM(
|
|
744
|
-
MIN(timestamp), MAX(timestamp + COALESCE(duration, 0))
|
|
745
|
-
FROM
|
|
746
|
-
].filter(([name]) => parts.has(name)).map(([name, sql]) => [name,
|
|
747
|
-
|
|
748
|
-
//
|
|
749
|
-
// read across every transcript and keyed by root, so a child that fell outside the window still
|
|
750
|
-
// folds into the session it belongs to.
|
|
716
|
+
['overall', `SELECT ${messageFacts()} FROM ${from} ${where}`],
|
|
717
|
+
['byModel', `SELECT ${COL.model}, ${COL.provider}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 3 DESC`],
|
|
718
|
+
['byProvider', `SELECT ${COL.provider}, COUNT(DISTINCT ${COL.model}), ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 3 DESC`],
|
|
719
|
+
['byAgentType', `SELECT ${COL.agentType}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
720
|
+
['byProject', `SELECT ${COL.folder}, ${messageFacts()} FROM ${from} ${where} GROUP BY 1 ORDER BY 2 DESC`],
|
|
721
|
+
['series', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, COUNT(*),
|
|
722
|
+
SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END),
|
|
723
|
+
SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
|
|
724
|
+
['hourOfDay', `SELECT CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
725
|
+
COUNT(*), SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END), SUM(${COL.total}), TOTAL(${COL.costTotal})
|
|
726
|
+
FROM ${from} ${where} GROUP BY 1 ORDER BY 1`],
|
|
727
|
+
['recent', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${where} ORDER BY ${COL.timestamp} DESC LIMIT ${RECENT_LIMIT} OFFSET ${Math.max(0, Math.floor(offset))}`],
|
|
728
|
+
['errors', `SELECT ${REQUEST_COLUMNS.join(', ')} FROM ${from} ${errorWhere} ORDER BY ${COL.timestamp} DESC LIMIT ${ERROR_LIMIT}`],
|
|
729
|
+
['traces', `SELECT ${COL.session}, MAX(${COL.folder}),
|
|
730
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}), SUM(${COL.unpriced}),
|
|
731
|
+
MIN(${COL.timestamp}), MAX(${COL.timestamp} + COALESCE(${COL.duration}, 0))
|
|
732
|
+
FROM ${from} ${where} GROUP BY 1 ORDER BY 8 DESC LIMIT ${TRACE_LIMIT}`],
|
|
733
|
+
].filter(([name]) => parts.has(name)).map(([name, sql]) => [name, sql] as [string, string]));
|
|
734
|
+
|
|
735
|
+
// Models and tools are keyed by root, so a child outside the window still folds into its session.
|
|
751
736
|
const traceModels = readTraceModels(db);
|
|
752
737
|
const traceTools = readTraceToolCounts(db);
|
|
753
738
|
const traces = foldTraces((out.get('traces') ?? []).map((r) => {
|
|
@@ -816,13 +801,7 @@ function readStatsDb(db: string, cutoff: number, bucketMs: number, cols: Set<str
|
|
|
816
801
|
};
|
|
817
802
|
}
|
|
818
803
|
|
|
819
|
-
/**
|
|
820
|
-
* Fold every child transcript into its root session, the way the reference lists them.
|
|
821
|
-
*
|
|
822
|
-
* A subagent writes its own file under its root's folder, so a row grouped by file alone would
|
|
823
|
-
* show one row per subagent. Each root holds its children's requests, tokens, cost, models, and
|
|
824
|
-
* time, counts the children folded in, and keeps the row even when its own file is gone.
|
|
825
|
-
*/
|
|
804
|
+
/** Fold every child transcript into its root session, or a row per file would show one row per subagent. */
|
|
826
805
|
function foldTraces(rows: AgentTraceRow[]): AgentTraceRow[] {
|
|
827
806
|
const byRoot = new Map<string, AgentTraceRow>();
|
|
828
807
|
for (const row of rows) {
|
|
@@ -904,9 +883,9 @@ function groupErrors(rows: AgentRequestRow[]): AgentErrorGroup[] {
|
|
|
904
883
|
}
|
|
905
884
|
|
|
906
885
|
function readProviderSeries(db: string, cutoff: number, bucketMs: number): Array<{ provider: string; points: AgentBucket[] }> {
|
|
907
|
-
const out = readBlocks(db, [['providerSeries', `SELECT ${
|
|
908
|
-
COUNT(*), SUM(
|
|
909
|
-
FROM
|
|
886
|
+
const out = readBlocks(db, [['providerSeries', `SELECT ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
887
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal})
|
|
888
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
910
889
|
const perProvider = new Map<string, { cost: number; points: AgentBucket[] }>();
|
|
911
890
|
for (const r of out.get('providerSeries') ?? []) {
|
|
912
891
|
const provider = r[0] ?? '';
|
|
@@ -922,48 +901,44 @@ function readProviderSeries(db: string, cutoff: number, bucketMs: number): Array
|
|
|
922
901
|
.map(([provider, held]) => ({ provider, points: held.points }));
|
|
923
902
|
}
|
|
924
903
|
|
|
904
|
+
/** Tool calls from each message's names array: a name is one whole call, so per-call sizes read as zero. */
|
|
925
905
|
function readToolSeries(db: string, cutoff: number, bucketMs: number): Partial<AgentStats> {
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
const
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
${
|
|
932
|
-
${txt('t.tool_name')}, ${txt('t.model')}, ${txt('t.provider')},
|
|
906
|
+
const where = `WHERE ${COL.timestamp} >= ${Math.floor(cutoff)}`;
|
|
907
|
+
const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
|
|
908
|
+
const from = `${SOURCE}, json_each(${names}) te`;
|
|
909
|
+
const facts = `
|
|
910
|
+
${COL.timestamp},
|
|
911
|
+
te.value, ${COL.model}, ${COL.provider},
|
|
933
912
|
COUNT(*),
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn),
|
|
939
|
-
TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn),
|
|
940
|
-
TOTAL(CASE WHEN COALESCE(m.total_tokens, 0) > 0 AND COALESCE(m.cost_total, 0) = 0
|
|
941
|
-
AND (m.provider = 'xai-oauth' OR m.cost_unpriced = 1) THEN 1 ELSE 0 END * 1.0 / t.calls_in_turn),
|
|
942
|
-
MAX(t.timestamp)${extra}`;
|
|
913
|
+
TOTAL(${COL.total}),
|
|
914
|
+
TOTAL(${COL.output}),
|
|
915
|
+
TOTAL(${COL.costTotal}),
|
|
916
|
+
MAX(${COL.timestamp})`;
|
|
943
917
|
const out = readBlocks(db, [
|
|
944
|
-
['byTool', `SELECT ${facts
|
|
945
|
-
['byToolModel', `SELECT ${facts
|
|
946
|
-
|
|
947
|
-
['toolSeries', `SELECT (
|
|
948
|
-
SUM(CASE WHEN
|
|
918
|
+
['byTool', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value ORDER BY 5 DESC`],
|
|
919
|
+
['byToolModel', `SELECT ${facts} FROM ${from} ${where} GROUP BY te.value, ${COL.model}, ${COL.provider}
|
|
920
|
+
ORDER BY 5 DESC LIMIT ${TOOL_MODEL_LIMIT}`],
|
|
921
|
+
['toolSeries', `SELECT (${COL.bucket} / ${bucketMs}) * ${bucketMs}, te.value, COUNT(*),
|
|
922
|
+
SUM(CASE WHEN ${COL.stopReason} = 'error' THEN 1 ELSE 0 END)
|
|
923
|
+
FROM ${from} ${where} GROUP BY 1, 2 ORDER BY 1`],
|
|
949
924
|
]);
|
|
950
|
-
const parseTool = (r: string[]
|
|
925
|
+
const parseTool = (r: string[]): AgentToolRow => ({
|
|
951
926
|
tool: r[1] ?? '',
|
|
952
927
|
calls: num(r[4]),
|
|
953
|
-
errors:
|
|
954
|
-
argsChars:
|
|
955
|
-
resultChars:
|
|
956
|
-
totalTokensShare: num(r[
|
|
957
|
-
outputTokensShare: num(r[
|
|
958
|
-
costShare: num(r[
|
|
959
|
-
unpricedShare:
|
|
960
|
-
lastUsed: numOrNull(r[
|
|
961
|
-
model: r[
|
|
962
|
-
provider: r[
|
|
928
|
+
errors: 0,
|
|
929
|
+
argsChars: 0,
|
|
930
|
+
resultChars: 0,
|
|
931
|
+
totalTokensShare: num(r[5]),
|
|
932
|
+
outputTokensShare: num(r[6]),
|
|
933
|
+
costShare: num(r[7]),
|
|
934
|
+
unpricedShare: 0,
|
|
935
|
+
lastUsed: numOrNull(r[8]),
|
|
936
|
+
model: r[2] ?? '',
|
|
937
|
+
provider: r[3] ?? '',
|
|
963
938
|
});
|
|
964
939
|
return {
|
|
965
|
-
tools: (out.get('byTool') ?? []).map(
|
|
966
|
-
toolsByModel: (out.get('byToolModel') ?? []).map(
|
|
940
|
+
tools: (out.get('byTool') ?? []).map(parseTool),
|
|
941
|
+
toolsByModel: (out.get('byToolModel') ?? []).map(parseTool),
|
|
967
942
|
toolSeries: (out.get('toolSeries') ?? []).map((r) => ({
|
|
968
943
|
ts: num(r[0]), tool: r[1] ?? '', calls: num(r[2]), errors: num(r[3]),
|
|
969
944
|
})),
|
|
@@ -974,7 +949,9 @@ function readToolSeries(db: string, cutoff: number, bucketMs: number): Partial<A
|
|
|
974
949
|
function readTraceToolCounts(db: string): Map<string, number> {
|
|
975
950
|
const counts = new Map<string, number>();
|
|
976
951
|
try {
|
|
977
|
-
const
|
|
952
|
+
const names = `CASE WHEN m.tools LIKE '[%' THEN m.tools ELSE '[]' END`;
|
|
953
|
+
const out = sqliteScript(db,
|
|
954
|
+
`SELECT ${COL.session}, COUNT(*) FROM ${SOURCE}, json_each(${names}) te GROUP BY 1;\n`);
|
|
978
955
|
for (const line of out.split('\n')) {
|
|
979
956
|
if (!line.trim()) continue;
|
|
980
957
|
const [file, count] = line.split('\t');
|
|
@@ -990,7 +967,7 @@ function readTraceToolCounts(db: string): Map<string, number> {
|
|
|
990
967
|
function readTraceModels(db: string): Map<string, string[]> {
|
|
991
968
|
const models = new Map<string, Set<string>>();
|
|
992
969
|
try {
|
|
993
|
-
const out = sqliteScript(db,
|
|
970
|
+
const out = sqliteScript(db, `SELECT ${COL.session}, ${COL.model} FROM ${SOURCE} GROUP BY 1, 2;\n`);
|
|
994
971
|
for (const line of out.split('\n')) {
|
|
995
972
|
if (!line.trim()) continue;
|
|
996
973
|
const [file, model] = line.split('\t');
|
|
@@ -1003,12 +980,7 @@ function readTraceModels(db: string): Map<string, string[]> {
|
|
|
1003
980
|
return new Map([...models.entries()].map(([file, set]) => [file, [...set].toSorted()]));
|
|
1004
981
|
}
|
|
1005
982
|
|
|
1006
|
-
/**
|
|
1007
|
-
* Each root session's own title and working directory, read from the head of its transcript.
|
|
1008
|
-
*
|
|
1009
|
-
* The `session` line carries the working directory and the `title` line sits above it, so one head
|
|
1010
|
-
* read answers both. A rotated or unreadable file leaves the row's folder and file name to speak.
|
|
1011
|
-
*/
|
|
983
|
+
/** A session's title and cwd: the `session` line carries the directory and the `title` line sits above it. */
|
|
1012
984
|
function applySessionMetadata(traces: AgentTraceRow[]): void {
|
|
1013
985
|
for (const trace of traces) {
|
|
1014
986
|
if (!trace.sessionFile.endsWith('.jsonl')) continue;
|
|
@@ -1038,11 +1010,11 @@ function applySessionMetadata(traces: AgentTraceRow[]): void {
|
|
|
1038
1010
|
|
|
1039
1011
|
/** Daily tokens and cost for the busiest models: the Models table sparkline. */
|
|
1040
1012
|
function readModelSeries(db: string, cutoff: number, bucketMs: number): Array<{ model: string; points: AgentBucket[] }> {
|
|
1041
|
-
const scope = rangeWhere(cutoff);
|
|
1042
|
-
const top =
|
|
1013
|
+
const scope = rangeWhere(cutoff, COL.timestamp);
|
|
1014
|
+
const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
1043
1015
|
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
1044
|
-
const out = readBlocks(db, [['modelSeries', `SELECT ${
|
|
1045
|
-
COUNT(*), SUM(
|
|
1016
|
+
const out = readBlocks(db, [['modelSeries', `SELECT ${COL.model}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
1017
|
+
COUNT(*), SUM(${COL.total}), TOTAL(${COL.costTotal}) FROM ${SOURCE} ${clause} GROUP BY 1, 2 ORDER BY 1, 2`]]);
|
|
1046
1018
|
const perModel = new Map<string, AgentBucket[]>();
|
|
1047
1019
|
for (const r of out.get('modelSeries') ?? []) {
|
|
1048
1020
|
const model = r[0] ?? '';
|
|
@@ -1053,22 +1025,18 @@ function readModelSeries(db: string, cutoff: number, bucketMs: number): Array<{
|
|
|
1053
1025
|
return [...perModel.entries()].map(([model, points]) => ({ model, points }));
|
|
1054
1026
|
}
|
|
1055
1027
|
|
|
1056
|
-
/**
|
|
1057
|
-
* Throughput and first-token latency per bucket for the busiest models, the chart behind a
|
|
1058
|
-
* model's expanded row. Both averages are null in a bucket where no request carried the timing,
|
|
1059
|
-
* because a non-null zero would draw a flat line through a real gap.
|
|
1060
|
-
*/
|
|
1028
|
+
/** Per-bucket throughput and ttft for the busiest models; a null average means no request carried timing. */
|
|
1061
1029
|
function readModelPerformance(db: string, cutoff: number, bucketMs: number): Array<{ model: string; provider: string; points: AgentModelPerformancePoint[] }> {
|
|
1062
|
-
const scope = rangeWhere(cutoff);
|
|
1063
|
-
const top =
|
|
1030
|
+
const scope = rangeWhere(cutoff, COL.timestamp);
|
|
1031
|
+
const top = `${COL.model} IN (SELECT ${COL.model} FROM ${SOURCE} ${scope} GROUP BY ${COL.model} ORDER BY COUNT(*) DESC LIMIT ${SERIES_MODELS})`;
|
|
1064
1032
|
const clause = scope ? `${scope} AND ${top}` : `WHERE ${top}`;
|
|
1065
|
-
const out = readBlocks(db, [['modelPerformance', `SELECT ${
|
|
1033
|
+
const out = readBlocks(db, [['modelPerformance', `SELECT ${COL.model}, ${COL.provider}, (${COL.bucket} / ${bucketMs}) * ${bucketMs},
|
|
1066
1034
|
COUNT(*),
|
|
1067
|
-
TOTAL(CASE WHEN duration > 0 THEN
|
|
1068
|
-
COUNT(CASE WHEN duration > 0 THEN 1 END),
|
|
1069
|
-
TOTAL(ttft),
|
|
1070
|
-
COUNT(ttft)
|
|
1071
|
-
FROM
|
|
1035
|
+
TOTAL(CASE WHEN ${COL.duration} > 0 THEN ${COL.output} * 1000.0 / ${COL.duration} END),
|
|
1036
|
+
COUNT(CASE WHEN ${COL.duration} > 0 THEN 1 END),
|
|
1037
|
+
TOTAL(${COL.ttft}),
|
|
1038
|
+
COUNT(${COL.ttft})
|
|
1039
|
+
FROM ${SOURCE} ${clause} GROUP BY 1, 2, 3 ORDER BY 1, 2, 3`]]);
|
|
1072
1040
|
const groups = new Map<string, { model: string; provider: string; points: AgentModelPerformancePoint[] }>();
|
|
1073
1041
|
for (const r of out.get('modelPerformance') ?? []) {
|
|
1074
1042
|
const key = `${r[0] ?? ''}\u0000${r[1] ?? ''}`;
|
|
@@ -1086,8 +1054,6 @@ function readModelPerformance(db: string, cutoff: number, bucketMs: number): Arr
|
|
|
1086
1054
|
return [...groups.values()];
|
|
1087
1055
|
}
|
|
1088
1056
|
|
|
1089
|
-
// ------------------------------------------------------------ subscription windows
|
|
1090
|
-
|
|
1091
1057
|
/** Reference constants for the quota half, copied from the omp implementation. */
|
|
1092
1058
|
const WINDOW_RESET_DROP = 0.05;
|
|
1093
1059
|
const WINDOW_MIN_EXTRAPOLATION = 0.1;
|
|
@@ -1128,10 +1094,7 @@ function downsampleWindow(points: AgentWindowPoint[]): AgentWindowPoint[] {
|
|
|
1128
1094
|
return out;
|
|
1129
1095
|
}
|
|
1130
1096
|
|
|
1131
|
-
/**
|
|
1132
|
-
* Peak of the sum across accounts at any sampled instant, forward filling each account's last
|
|
1133
|
-
* known fraction. A peak of 1.7 means demand simultaneously held 1.7 windows of quota.
|
|
1134
|
-
*/
|
|
1097
|
+
/** Peak summed quota across accounts, forward-filling last known fractions (1.7 = 1.7 windows held). */
|
|
1135
1098
|
function peakConcurrent(samples: WindowSample[]): number {
|
|
1136
1099
|
const events = samples.filter((sample) => sample.usedFraction !== null).toSorted((a, b) => a.ts - b.ts);
|
|
1137
1100
|
const current = new Map<string, number>();
|
|
@@ -1177,10 +1140,7 @@ function readWindowSamples(cutoff: number): WindowSample[] {
|
|
|
1177
1140
|
});
|
|
1178
1141
|
}
|
|
1179
1142
|
|
|
1180
|
-
/**
|
|
1181
|
-
* Utilization series and per-window insights. A window is one `(provider, limitId)` pair: distinct
|
|
1182
|
-
* limits can share a duration label, and merging them interleaves unrelated fractions per account.
|
|
1183
|
-
*/
|
|
1143
|
+
/** Window utilization; a window is one `(provider, limitId)` pair, since limits can share a duration. */
|
|
1184
1144
|
function computeWindows(
|
|
1185
1145
|
samples: WindowSample[],
|
|
1186
1146
|
tokensByProvider: Map<string, number>,
|
|
@@ -1268,17 +1228,17 @@ function computeWindows(
|
|
|
1268
1228
|
|
|
1269
1229
|
/** Tokens per provider over the range: the currency a window insight extrapolates in. */
|
|
1270
1230
|
function readProviderTokens(db: string, cutoff: number): Map<string, number> {
|
|
1271
|
-
const out = readBlocks(db, [['providerTokens', `SELECT ${
|
|
1272
|
-
FROM
|
|
1231
|
+
const out = readBlocks(db, [['providerTokens', `SELECT ${COL.provider}, SUM(${COL.total})
|
|
1232
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1`]]);
|
|
1273
1233
|
return new Map((out.get('providerTokens') ?? []).map((r) => [r[0] ?? '', num(r[1])]));
|
|
1274
1234
|
}
|
|
1275
1235
|
|
|
1276
1236
|
/** Peak burn hours: one row per provider per local hour, the shape the reference charts. */
|
|
1277
1237
|
function readProviderHourly(db: string, cutoff: number): AgentProviderHour[] {
|
|
1278
|
-
const out = readBlocks(db, [['providerHourly', `SELECT ${
|
|
1279
|
-
CAST(strftime('%H',
|
|
1280
|
-
SUM(
|
|
1281
|
-
FROM
|
|
1238
|
+
const out = readBlocks(db, [['providerHourly', `SELECT ${COL.provider},
|
|
1239
|
+
CAST(strftime('%H', ${COL.bucket} / 1000, 'unixepoch', 'localtime') AS INTEGER),
|
|
1240
|
+
SUM(${COL.total}), SUM(${COL.output}), COUNT(*)
|
|
1241
|
+
FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)} GROUP BY 1, 2`]]);
|
|
1282
1242
|
return (out.get('providerHourly') ?? []).map((r) => ({
|
|
1283
1243
|
provider: r[0] ?? '',
|
|
1284
1244
|
hour: num(r[1]),
|
|
@@ -1296,24 +1256,25 @@ function median(values: Array<number | null | undefined>): number {
|
|
|
1296
1256
|
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
|
|
1297
1257
|
}
|
|
1298
1258
|
|
|
1299
|
-
/**
|
|
1300
|
-
function computeRequestStats(rows: AgentRequestRow[]): AgentRequestStats {
|
|
1259
|
+
/** Requests summary; percentiles stay over the capped slice, so a p95 of 200 of 2,400 describes those 200. */
|
|
1260
|
+
function computeRequestStats(rows: AgentRequestRow[], range?: AgentOverall): AgentRequestStats {
|
|
1301
1261
|
const durations = rows.map((row) => row.durationMs);
|
|
1302
1262
|
const sorted = durations.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)).toSorted((a, b) => a - b);
|
|
1303
1263
|
const pick = (q: number): number => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
|
|
1304
1264
|
const times = rows.map((row) => row.ts);
|
|
1305
1265
|
return {
|
|
1306
|
-
requests: rows.length,
|
|
1307
|
-
failed: rows.filter((row) => row.stopReason === 'error').length,
|
|
1266
|
+
requests: range ? range.requests : rows.length,
|
|
1267
|
+
failed: range ? range.failed : rows.filter((row) => row.stopReason === 'error').length,
|
|
1308
1268
|
aborted: rows.filter((row) => row.stopReason === 'aborted').length,
|
|
1309
|
-
tokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
1310
|
-
costUsd: rows.reduce((sum, row) => sum + row.costUsd, 0),
|
|
1311
|
-
unpriced: rows.filter((row) => row.unpriced).length,
|
|
1269
|
+
tokens: range ? range.total : rows.reduce((sum, row) => sum + row.totalTokens, 0),
|
|
1270
|
+
costUsd: range ? range.costUsd : rows.reduce((sum, row) => sum + row.costUsd, 0),
|
|
1271
|
+
unpriced: range ? range.unpricedRequests : rows.filter((row) => row.unpriced).length,
|
|
1272
|
+
shipped: rows.length,
|
|
1312
1273
|
medianDurationMs: median(durations),
|
|
1313
1274
|
p95DurationMs: pick(0.95),
|
|
1314
1275
|
medianTtftMs: median(rows.map((row) => row.ttftMs)),
|
|
1315
|
-
oldest: times.length ? Math.min(...times) : null,
|
|
1316
|
-
newest: times.length ? Math.max(...times) : null,
|
|
1276
|
+
oldest: range ? range.firstTs : times.length ? Math.min(...times) : null,
|
|
1277
|
+
newest: range ? range.lastTs : times.length ? Math.max(...times) : null,
|
|
1317
1278
|
};
|
|
1318
1279
|
}
|
|
1319
1280
|
|
|
@@ -1341,15 +1302,7 @@ function stopState(stopReason: string): AgentTraceSpan['stopped'] {
|
|
|
1341
1302
|
/** Tool names that hand the work to another agent, for the Agents figure. */
|
|
1342
1303
|
const AGENT_TOOLS = new Set(['task', 'subagent']);
|
|
1343
1304
|
|
|
1344
|
-
/**
|
|
1345
|
-
* One session transcript as spans, tracks, markers, and totals.
|
|
1346
|
-
*
|
|
1347
|
-
* An assistant message stamps its completion and its `duration` says when the call began, so a
|
|
1348
|
-
* model span runs `ts - duration` to `ts`. A tool call is emitted by the message requesting it and
|
|
1349
|
-
* closed by the `toolResult` with the same call id, so the two bound a tool span. A user message
|
|
1350
|
-
* opens a turn the next user message closes, and everything that is not a message rides along as a
|
|
1351
|
-
* marker, since it happened at a point in time rather than across one.
|
|
1352
|
-
*/
|
|
1305
|
+
/** One transcript as spans, tracks and markers: a model span runs `ts - duration` to `ts`. */
|
|
1353
1306
|
export function readSessionTrace(sessionFile: string, limit = TRACE_SPAN_LIMIT): AgentSessionTrace {
|
|
1354
1307
|
const project = sessionFile.split('/sessions/')[1]?.split('/')[0] ?? '';
|
|
1355
1308
|
const empty: AgentSessionTrace = {
|
|
@@ -1575,12 +1528,7 @@ export function emptyRequestEntry(): { entry: unknown; output: unknown; messageR
|
|
|
1575
1528
|
return { entry: null, output: null, messageRole: '', agent: { model: null, thinkingLevel: null, mode: null, fallback: null } };
|
|
1576
1529
|
}
|
|
1577
1530
|
|
|
1578
|
-
/**
|
|
1579
|
-
* One request's journal payload, read on demand by the request drawer. `entry` is the raw
|
|
1580
|
-
* journal line, `output` is the message that line carries, and `agent` is the agent state in
|
|
1581
|
-
* force when the request ran. All are empty when the entry journals nothing: a `model_usage`
|
|
1582
|
-
* row carries no message, and a deleted session cannot be re-read.
|
|
1583
|
-
*/
|
|
1531
|
+
/** One request's journal payload; empty when the entry journals nothing or its session is gone. */
|
|
1584
1532
|
export function readAgentRequestEntry(
|
|
1585
1533
|
sessionFile: string,
|
|
1586
1534
|
entryId: string,
|
|
@@ -1621,8 +1569,8 @@ export function readAgentRequestEntry(
|
|
|
1621
1569
|
}
|
|
1622
1570
|
|
|
1623
1571
|
|
|
1624
|
-
/** Reads
|
|
1625
|
-
export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all', now = Date.now()): AgentStats {
|
|
1572
|
+
/** Reads the usage store; the caller runs `syncUsageDb`, so a poll never pays for a sync it did not start. */
|
|
1573
|
+
export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all', now = Date.now(), offset = 0): AgentStats {
|
|
1626
1574
|
const span = RANGE_MS[range];
|
|
1627
1575
|
const cutoff = span > 0 ? now - span : 0;
|
|
1628
1576
|
const parts = new Set(VIEW_PARTS[view]);
|
|
@@ -1637,18 +1585,18 @@ export function readAgentStats(range: RangeKey = '24h', view: AgentView = 'all',
|
|
|
1637
1585
|
}
|
|
1638
1586
|
|
|
1639
1587
|
// The bucket width depends on the span, so the bounds come first.
|
|
1640
|
-
const bounds = readBlocks(db, [['bounds', `SELECT MIN(timestamp), MAX(timestamp) FROM
|
|
1588
|
+
const bounds = readBlocks(db, [['bounds', `SELECT MIN(${COL.timestamp}), MAX(${COL.timestamp}) FROM ${SOURCE} ${rangeWhere(cutoff, COL.timestamp)}`]]);
|
|
1641
1589
|
const [firstTs, lastTs] = bounds.get('bounds')?.[0] ?? [];
|
|
1642
1590
|
stats.bucketMs = bucketSize(range, numOrNull(firstTs), numOrNull(lastTs));
|
|
1643
1591
|
|
|
1644
|
-
Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs,
|
|
1592
|
+
Object.assign(stats, readStatsDb(db, cutoff, stats.bucketMs, parts, offset));
|
|
1645
1593
|
if (parts.has('seriesByProvider')) stats.seriesByProvider = readProviderSeries(db, cutoff, stats.bucketMs);
|
|
1646
1594
|
if (parts.has('modelSeries')) stats.modelSeries = readModelSeries(db, cutoff, stats.bucketMs);
|
|
1647
1595
|
if (parts.has('modelPerformance')) stats.modelPerformance = readModelPerformance(db, cutoff, stats.bucketMs);
|
|
1648
1596
|
if (parts.has('tools')) Object.assign(stats, readToolSeries(db, cutoff, stats.bucketMs));
|
|
1649
1597
|
if (stats.byModel.length > 0) stats.topModels = stats.byModel.slice(0, 12);
|
|
1650
1598
|
if (parts.has('providerHourly')) stats.providerHourly = readProviderHourly(db, cutoff);
|
|
1651
|
-
if (parts.has('recent')) stats.requestStats = computeRequestStats(stats.recent);
|
|
1599
|
+
if (parts.has('recent')) stats.requestStats = computeRequestStats(stats.recent, stats.overall);
|
|
1652
1600
|
if (parts.has('windows')) {
|
|
1653
1601
|
Object.assign(stats, computeWindows(readWindowSamples(cutoff), readProviderTokens(db, cutoff)));
|
|
1654
1602
|
}
|