@oh-my-pi/omp-stats 18.1.16 → 18.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/client/index.js +93 -93
- package/dist/types/client/data/formatters.d.ts +7 -2
- package/dist/types/client/types.d.ts +2 -0
- package/dist/types/db.d.ts +5 -2
- package/dist/types/parser.d.ts +28 -2
- package/dist/types/shared-types.d.ts +4 -0
- package/dist/types/trace.d.ts +1 -1
- package/dist/types/types.d.ts +21 -0
- package/package.json +4 -4
- package/src/client/data/formatters.ts +15 -3
- package/src/client/routes/TracesRoute.tsx +10 -3
- package/src/client/traces/SummaryStrip.tsx +2 -2
- package/src/client/types.ts +2 -0
- package/src/db.ts +181 -46
- package/src/parser.ts +80 -18
- package/src/shared-types.ts +4 -0
- package/src/trace.ts +52 -8
- package/src/types.ts +20 -0
package/src/db.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
DailyActivityPoint,
|
|
22
22
|
FolderStats,
|
|
23
23
|
MessageStats,
|
|
24
|
+
MessageStatsInput,
|
|
24
25
|
ModelPerformancePoint,
|
|
25
26
|
ModelStats,
|
|
26
27
|
ModelTimeSeriesPoint,
|
|
@@ -48,13 +49,35 @@ const ZERO_USAGE_COST: UsageCost = {
|
|
|
48
49
|
total: 0,
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Predicate counting one stored request as "unpriced" — the public rate card
|
|
54
|
+
* has no charge for it, so its zero is unknown spend rather than free usage.
|
|
55
|
+
* Two shapes store that zero:
|
|
56
|
+
* - `xai-oauth` bills through the SuperGrok subscription, so ingestion
|
|
57
|
+
* deliberately records no per-request price;
|
|
58
|
+
* - `cost_unpriced = 1`, set by `insertMessageStats` when `resolveStoredCost`
|
|
59
|
+
* refuses to price a scheduled (time-based) card whose entry carried no
|
|
60
|
+
* recoverable request timestamp — the epoch would silently become the
|
|
61
|
+
* tariff. Such a row keeps its real tokens and its zero until a re-parse
|
|
62
|
+
* recovers the time.
|
|
63
|
+
*
|
|
64
|
+
* Nothing else sets the marker: an explicit recorded zero, a free flat card,
|
|
65
|
+
* and a model with no catalog card at all keep `cost_unpriced = 0` even at the
|
|
66
|
+
* parser's timestamp sentinel, because their zero is a real price.
|
|
67
|
+
* `prefix` qualifies the columns for queries that alias `messages`.
|
|
68
|
+
*/
|
|
69
|
+
function unpricedRequestSql(prefix = ""): string {
|
|
70
|
+
return `CASE WHEN ${prefix}total_tokens > 0 AND ${prefix}cost_total = 0
|
|
71
|
+
AND (${prefix}provider = 'xai-oauth' OR ${prefix}cost_unpriced = 1) THEN 1 ELSE 0 END`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const UNPRICED_REQUEST_SQL = unpricedRequestSql();
|
|
53
75
|
|
|
54
76
|
interface CostBackfillRow {
|
|
55
77
|
id: number;
|
|
56
78
|
provider: string;
|
|
57
79
|
model: string;
|
|
80
|
+
timestamp: number;
|
|
58
81
|
input_tokens: number;
|
|
59
82
|
output_tokens: number;
|
|
60
83
|
cache_read_tokens: number;
|
|
@@ -65,6 +88,7 @@ interface NoCacheInputCostBackfillRow {
|
|
|
65
88
|
id: number;
|
|
66
89
|
provider: string;
|
|
67
90
|
model: string;
|
|
91
|
+
timestamp: number;
|
|
68
92
|
input_tokens: number;
|
|
69
93
|
cache_read_tokens: number;
|
|
70
94
|
cache_write_tokens: number;
|
|
@@ -127,6 +151,11 @@ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
|
|
|
127
151
|
// reach the inclusive 200K tier. A one-time full re-parse repairs them through
|
|
128
152
|
// the cost-refreshing UPSERT in `insertMessageStats`.
|
|
129
153
|
const COST_REINGEST_BACKFILL_KEY = "messages_cost_reingest_v1";
|
|
154
|
+
// The absence-aware `resolveStoredCost` and the `cost_unpriced` marker only
|
|
155
|
+
// reach already-ingested rows through a re-parse, and the reingest sentinel
|
|
156
|
+
// above is already spent for them — without this one, every historical row
|
|
157
|
+
// keeps `cost_unpriced = 0` and unknown scheduled spend reports as free.
|
|
158
|
+
const COST_UNPRICED_BACKFILL_KEY = "messages_cost_unpriced_v1";
|
|
130
159
|
function shouldResetBackfill(value: string | undefined): boolean {
|
|
131
160
|
return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
|
|
132
161
|
}
|
|
@@ -177,6 +206,7 @@ export async function initDb(): Promise<Database> {
|
|
|
177
206
|
cost_cache_write REAL NOT NULL,
|
|
178
207
|
cost_total REAL NOT NULL,
|
|
179
208
|
cost_no_cache_input REAL,
|
|
209
|
+
cost_unpriced INTEGER NOT NULL DEFAULT 0,
|
|
180
210
|
agent_type TEXT NOT NULL DEFAULT 'main',
|
|
181
211
|
UNIQUE(session_file, entry_id)
|
|
182
212
|
);
|
|
@@ -251,6 +281,11 @@ export async function initDb(): Promise<Database> {
|
|
|
251
281
|
if (!messageColumns.some(column => column.name === "cost_no_cache_input")) {
|
|
252
282
|
db.run("ALTER TABLE messages ADD COLUMN cost_no_cache_input REAL");
|
|
253
283
|
}
|
|
284
|
+
// Rows ingested before this column existed default to 0 (not unpriced), so
|
|
285
|
+
// their epoch-sentinel zeros read as free until a re-parse rewrites them.
|
|
286
|
+
if (!messageColumns.some(column => column.name === "cost_unpriced")) {
|
|
287
|
+
db.run("ALTER TABLE messages ADD COLUMN cost_unpriced INTEGER NOT NULL DEFAULT 0");
|
|
288
|
+
}
|
|
254
289
|
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
|
|
255
290
|
// Token-usage-by-agent: each message is classified main / subagent / advisor
|
|
256
291
|
// from its transcript path. A brand-new table gets the column from CREATE
|
|
@@ -330,6 +365,7 @@ export async function initDb(): Promise<Database> {
|
|
|
330
365
|
backfillUserMessages(db);
|
|
331
366
|
backfillToolCalls(db);
|
|
332
367
|
backfillReingestCosts(db);
|
|
368
|
+
backfillUnpricedCosts(db);
|
|
333
369
|
repairUserMessageLinks(db);
|
|
334
370
|
backfillPriorityPremiumRequests(db);
|
|
335
371
|
backfillAgentType(db);
|
|
@@ -365,7 +401,17 @@ function getCatalogCost(provider: string, modelId: string): ModelCost | null {
|
|
|
365
401
|
return null;
|
|
366
402
|
}
|
|
367
403
|
|
|
368
|
-
|
|
404
|
+
/** Whether the catalog prices this model on a time-based (scheduled) card. */
|
|
405
|
+
export function isScheduledCatalogModel(provider: string, modelId: string): boolean {
|
|
406
|
+
return getCatalogCost(provider, modelId)?.timeBased != null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function calculateCatalogCost(
|
|
410
|
+
provider: string,
|
|
411
|
+
modelId: string,
|
|
412
|
+
tokens: CostTokens,
|
|
413
|
+
timestamp: number,
|
|
414
|
+
): UsageCost | null {
|
|
369
415
|
const cost = getCatalogCost(provider, modelId);
|
|
370
416
|
if (!cost) return null;
|
|
371
417
|
|
|
@@ -382,10 +428,10 @@ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTok
|
|
|
382
428
|
(orchestration?.cacheRead ?? 0),
|
|
383
429
|
cost: { ...ZERO_USAGE_COST },
|
|
384
430
|
};
|
|
385
|
-
return calculateUsageCost(cost, usage);
|
|
431
|
+
return calculateUsageCost(cost, usage, timestamp);
|
|
386
432
|
}
|
|
387
433
|
|
|
388
|
-
function normalizeUsageCost(cost: UsageCost): UsageCost {
|
|
434
|
+
function normalizeUsageCost(cost: Partial<UsageCost>): UsageCost {
|
|
389
435
|
const input = cost.input ?? 0;
|
|
390
436
|
const output = cost.output ?? 0;
|
|
391
437
|
const cacheRead = cost.cacheRead ?? 0;
|
|
@@ -394,37 +440,80 @@ function normalizeUsageCost(cost: UsageCost): UsageCost {
|
|
|
394
440
|
return { input, output, cacheRead, cacheWrite, total };
|
|
395
441
|
}
|
|
396
442
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
443
|
+
/**
|
|
444
|
+
* The parser records no timestamp for an entry that carries neither a numeric
|
|
445
|
+
* message timestamp nor a parseable entry timestamp, leaving this sentinel.
|
|
446
|
+
* Pricing such a request from the clock would bill it as a 1970 request.
|
|
447
|
+
*/
|
|
448
|
+
function hasRequestTimestamp(timestamp: number): boolean {
|
|
449
|
+
return Number.isFinite(timestamp) && timestamp > 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
interface ResolvedCost {
|
|
453
|
+
cost: UsageCost;
|
|
454
|
+
/**
|
|
455
|
+
* The stored zero is unknown spend rather than a price: a scheduled card
|
|
456
|
+
* with no recoverable request timestamp to select a tariff from. Persisted
|
|
457
|
+
* as `cost_unpriced` so the aggregates do not have to infer it.
|
|
458
|
+
*/
|
|
459
|
+
unpriced: boolean;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveStoredCost(stats: MessageStatsInput): ResolvedCost {
|
|
463
|
+
// `usage.cost` is absent when the session entry recorded no price at all, and
|
|
464
|
+
// legacy payloads can carry a partially-populated cost object (e.g. only
|
|
465
|
+
// `total`). The messages table declares every cost_* column as REAL NOT NULL,
|
|
466
|
+
// so any missing field must be normalised here before binding into SQLite.
|
|
467
|
+
const raw: Partial<UsageCost> | undefined = stats.usage.cost;
|
|
403
468
|
const storedCost = raw ? normalizeUsageCost(raw) : undefined;
|
|
469
|
+
const catalogCost = getCatalogCost(stats.provider, stats.model);
|
|
404
470
|
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
if (storedCost && (
|
|
471
|
+
// Scheduled prices are frozen per request, including explicitly free usage.
|
|
472
|
+
// Preserve legacy zero-cost subscription correction for unscheduled models.
|
|
473
|
+
if (storedCost && Number.isFinite(storedCost.total) && (storedCost.total !== 0 || catalogCost?.timeBased)) {
|
|
474
|
+
return { cost: storedCost, unpriced: false };
|
|
475
|
+
}
|
|
408
476
|
|
|
409
|
-
|
|
477
|
+
// Without a request timestamp a scheduled card has no tariff to select, and
|
|
478
|
+
// the epoch would silently become one. Leave the request unpriced; a re-parse
|
|
479
|
+
// of the session file repairs it once the timestamp is recoverable.
|
|
480
|
+
if (!hasRequestTimestamp(stats.timestamp) && catalogCost?.timeBased) {
|
|
481
|
+
return { cost: storedCost ?? ZERO_USAGE_COST, unpriced: true };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
cost:
|
|
486
|
+
calculateCatalogCost(stats.provider, stats.model, stats.usage, stats.timestamp) ??
|
|
487
|
+
storedCost ??
|
|
488
|
+
ZERO_USAGE_COST,
|
|
489
|
+
unpriced: false,
|
|
490
|
+
};
|
|
410
491
|
}
|
|
411
492
|
|
|
412
|
-
function calculateNoCacheInputCost(
|
|
493
|
+
function calculateNoCacheInputCost(
|
|
494
|
+
provider: string,
|
|
495
|
+
modelId: string,
|
|
496
|
+
tokens: CostTokens,
|
|
497
|
+
timestamp: number,
|
|
498
|
+
): number | null {
|
|
413
499
|
const cost = getCatalogCost(provider, modelId);
|
|
414
500
|
if (!cost) return null;
|
|
501
|
+
// Mirrors `resolveStoredCost`: an unpriced scheduled request must not report
|
|
502
|
+
// its whole prompt as cache savings just because the clock could be read.
|
|
503
|
+
if (!hasRequestTimestamp(timestamp) && cost.timeBased) return null;
|
|
415
504
|
const promptInputTokens =
|
|
416
505
|
tokens.input +
|
|
417
506
|
tokens.cacheRead +
|
|
418
507
|
tokens.cacheWrite +
|
|
419
508
|
(tokens.orchestration?.input ?? 0) +
|
|
420
509
|
(tokens.orchestration?.cacheRead ?? 0);
|
|
421
|
-
return calculateUncachedInputCost(cost, promptInputTokens);
|
|
510
|
+
return calculateUncachedInputCost(cost, promptInputTokens, timestamp);
|
|
422
511
|
}
|
|
423
512
|
|
|
424
513
|
function backfillMissingCatalogCosts(database: Database): void {
|
|
425
514
|
const rows = database
|
|
426
515
|
.prepare(`
|
|
427
|
-
SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
516
|
+
SELECT id, provider, model, timestamp, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
428
517
|
FROM messages
|
|
429
518
|
WHERE cost_total = 0 AND total_tokens > 0
|
|
430
519
|
`)
|
|
@@ -440,12 +529,24 @@ function backfillMissingCatalogCosts(database: Database): void {
|
|
|
440
529
|
|
|
441
530
|
const applyBackfill = database.transaction(() => {
|
|
442
531
|
for (const row of rows) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
532
|
+
// A stored zero cannot distinguish missing historical prices from an
|
|
533
|
+
// explicitly free request. Never reprice recorded scheduled usage: a
|
|
534
|
+
// zero ingested for a scheduled card is permanent, because this
|
|
535
|
+
// backfill skips those rows. Re-parsing the session file is the only
|
|
536
|
+
// repair, and it works only once the entry's absent `cost` reaches
|
|
537
|
+
// `resolveStoredCost` as absence rather than as a synthesized zero.
|
|
538
|
+
if (getCatalogCost(row.provider, row.model)?.timeBased) continue;
|
|
539
|
+
const cost = calculateCatalogCost(
|
|
540
|
+
row.provider,
|
|
541
|
+
row.model,
|
|
542
|
+
{
|
|
543
|
+
input: row.input_tokens,
|
|
544
|
+
output: row.output_tokens,
|
|
545
|
+
cacheRead: row.cache_read_tokens,
|
|
546
|
+
cacheWrite: row.cache_write_tokens,
|
|
547
|
+
},
|
|
548
|
+
row.timestamp,
|
|
549
|
+
);
|
|
449
550
|
|
|
450
551
|
if (!cost || cost.total === 0) continue;
|
|
451
552
|
|
|
@@ -459,7 +560,7 @@ function backfillMissingCatalogCosts(database: Database): void {
|
|
|
459
560
|
function backfillNoCacheInputCosts(database: Database): void {
|
|
460
561
|
const rows = database
|
|
461
562
|
.prepare(`
|
|
462
|
-
SELECT id, provider, model, input_tokens, cache_read_tokens, cache_write_tokens
|
|
563
|
+
SELECT id, provider, model, timestamp, input_tokens, cache_read_tokens, cache_write_tokens
|
|
463
564
|
FROM messages
|
|
464
565
|
WHERE cost_no_cache_input IS NULL
|
|
465
566
|
`)
|
|
@@ -469,12 +570,17 @@ function backfillNoCacheInputCosts(database: Database): void {
|
|
|
469
570
|
const update = database.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
|
|
470
571
|
const applyBackfill = database.transaction(() => {
|
|
471
572
|
for (const row of rows) {
|
|
472
|
-
const cost = calculateNoCacheInputCost(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
573
|
+
const cost = calculateNoCacheInputCost(
|
|
574
|
+
row.provider,
|
|
575
|
+
row.model,
|
|
576
|
+
{
|
|
577
|
+
input: row.input_tokens,
|
|
578
|
+
output: 0,
|
|
579
|
+
cacheRead: row.cache_read_tokens,
|
|
580
|
+
cacheWrite: row.cache_write_tokens,
|
|
581
|
+
},
|
|
582
|
+
row.timestamp,
|
|
583
|
+
);
|
|
478
584
|
update.run(cost ?? 0, row.id);
|
|
479
585
|
}
|
|
480
586
|
});
|
|
@@ -522,7 +628,7 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
|
|
|
522
628
|
* stored cost (orchestration-aware) and keeps `premium_requests` monotonic, so
|
|
523
629
|
* a forced re-parse repairs historical `premium_requests` and cost fix-ups.
|
|
524
630
|
*/
|
|
525
|
-
export function insertMessageStats(stats:
|
|
631
|
+
export function insertMessageStats(stats: MessageStatsInput[]): number {
|
|
526
632
|
if (!db || stats.length === 0) return 0;
|
|
527
633
|
|
|
528
634
|
const stmt = db.prepare(`
|
|
@@ -530,9 +636,10 @@ export function insertMessageStats(stats: MessageStats[]): number {
|
|
|
530
636
|
session_file, entry_id, folder, model, provider, api, timestamp,
|
|
531
637
|
duration, ttft, stop_reason, error_message,
|
|
532
638
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
|
|
533
|
-
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input,
|
|
639
|
+
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input,
|
|
640
|
+
cost_unpriced, agent_type
|
|
534
641
|
)
|
|
535
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
642
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
536
643
|
WHERE NOT EXISTS (
|
|
537
644
|
SELECT 1 FROM messages
|
|
538
645
|
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
|
|
@@ -544,14 +651,15 @@ export function insertMessageStats(stats: MessageStats[]): number {
|
|
|
544
651
|
cost_cache_read = excluded.cost_cache_read,
|
|
545
652
|
cost_cache_write = excluded.cost_cache_write,
|
|
546
653
|
cost_total = excluded.cost_total,
|
|
547
|
-
cost_no_cache_input = excluded.cost_no_cache_input
|
|
654
|
+
cost_no_cache_input = excluded.cost_no_cache_input,
|
|
655
|
+
cost_unpriced = excluded.cost_unpriced
|
|
548
656
|
`);
|
|
549
657
|
|
|
550
658
|
let inserted = 0;
|
|
551
659
|
const insert = db.transaction(() => {
|
|
552
660
|
for (const s of stats) {
|
|
553
|
-
const cost = resolveStoredCost(s);
|
|
554
|
-
const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage) ?? 0;
|
|
661
|
+
const { cost, unpriced } = resolveStoredCost(s);
|
|
662
|
+
const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage, s.timestamp) ?? 0;
|
|
555
663
|
const result = stmt.run(
|
|
556
664
|
s.sessionFile,
|
|
557
665
|
s.entryId,
|
|
@@ -576,6 +684,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
|
|
|
576
684
|
cost.cacheWrite,
|
|
577
685
|
cost.total,
|
|
578
686
|
noCacheInputCost,
|
|
687
|
+
unpriced ? 1 : 0,
|
|
579
688
|
s.agentType,
|
|
580
689
|
// `WHERE NOT EXISTS` binds: skip when a different session_file
|
|
581
690
|
// already holds this (entry_id, timestamp).
|
|
@@ -670,7 +779,7 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
|
|
|
670
779
|
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
671
780
|
SUM(premium_requests) as total_premium_requests,
|
|
672
781
|
SUM(cost_total) as total_cost,
|
|
673
|
-
SUM(${
|
|
782
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
674
783
|
SUM(CASE WHEN cost_no_cache_input > 0
|
|
675
784
|
THEN cost_input + cost_cache_read + cost_cache_write
|
|
676
785
|
ELSE 0 END) as total_cached_prompt_cost,
|
|
@@ -706,7 +815,7 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
|
|
|
706
815
|
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
707
816
|
SUM(premium_requests) as total_premium_requests,
|
|
708
817
|
SUM(cost_total) as total_cost,
|
|
709
|
-
SUM(${
|
|
818
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
710
819
|
SUM(CASE WHEN cost_no_cache_input > 0
|
|
711
820
|
THEN cost_input + cost_cache_read + cost_cache_write
|
|
712
821
|
ELSE 0 END) as total_cached_prompt_cost,
|
|
@@ -748,7 +857,7 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
|
|
|
748
857
|
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
749
858
|
SUM(premium_requests) as total_premium_requests,
|
|
750
859
|
SUM(cost_total) as total_cost,
|
|
751
|
-
SUM(${
|
|
860
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
752
861
|
SUM(CASE WHEN cost_no_cache_input > 0
|
|
753
862
|
THEN cost_input + cost_cache_read + cost_cache_write
|
|
754
863
|
ELSE 0 END) as total_cached_prompt_cost,
|
|
@@ -897,7 +1006,7 @@ export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[]
|
|
|
897
1006
|
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
898
1007
|
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
899
1008
|
SUM(cost_total) as total_cost,
|
|
900
|
-
SUM(${
|
|
1009
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
901
1010
|
SUM(premium_requests) as total_premium_requests,
|
|
902
1011
|
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
903
1012
|
FROM messages
|
|
@@ -995,7 +1104,7 @@ export function getProviderTimeSeries(
|
|
|
995
1104
|
provider,
|
|
996
1105
|
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
997
1106
|
SUM(cost_total) as cost,
|
|
998
|
-
SUM(${
|
|
1107
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
999
1108
|
COUNT(*) as requests
|
|
1000
1109
|
FROM messages
|
|
1001
1110
|
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
@@ -1118,6 +1227,7 @@ function rowToMessageStats(row: any): MessageStats {
|
|
|
1118
1227
|
},
|
|
1119
1228
|
},
|
|
1120
1229
|
agentType: (row.agent_type as AgentType) ?? "main",
|
|
1230
|
+
costUnpriced: row.cost_unpriced === 1,
|
|
1121
1231
|
};
|
|
1122
1232
|
}
|
|
1123
1233
|
|
|
@@ -1159,6 +1269,7 @@ export interface SessionRollupRow {
|
|
|
1159
1269
|
endedAt: number;
|
|
1160
1270
|
totalTokens: number;
|
|
1161
1271
|
costTotal: number;
|
|
1272
|
+
unpricedRequests: number;
|
|
1162
1273
|
/** Comma-joined DISTINCT models. */
|
|
1163
1274
|
models: string;
|
|
1164
1275
|
}
|
|
@@ -1173,6 +1284,7 @@ export function getSessionRollups(): SessionRollupRow[] {
|
|
|
1173
1284
|
MAX(timestamp + COALESCE(duration, 0)) AS endedAt,
|
|
1174
1285
|
SUM(total_tokens) AS totalTokens,
|
|
1175
1286
|
SUM(cost_total) AS costTotal,
|
|
1287
|
+
SUM(${UNPRICED_REQUEST_SQL}) AS unpricedRequests,
|
|
1176
1288
|
GROUP_CONCAT(DISTINCT model) AS models
|
|
1177
1289
|
FROM messages
|
|
1178
1290
|
GROUP BY session_file
|
|
@@ -1208,7 +1320,7 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
|
|
|
1208
1320
|
model,
|
|
1209
1321
|
provider,
|
|
1210
1322
|
SUM(cost_total) as cost,
|
|
1211
|
-
SUM(${
|
|
1323
|
+
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
1212
1324
|
SUM(cost_input) as cost_input,
|
|
1213
1325
|
SUM(cost_output) as cost_output,
|
|
1214
1326
|
SUM(cost_cache_read) as cost_cache_read,
|
|
@@ -1366,6 +1478,27 @@ function backfillReingestCosts(database: Database): void {
|
|
|
1366
1478
|
.run(COST_REINGEST_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1367
1479
|
}
|
|
1368
1480
|
|
|
1481
|
+
/**
|
|
1482
|
+
* One-shot `file_offsets` wipe so the next sync re-parses every session and
|
|
1483
|
+
* re-derives `cost_unpriced` from `resolveStoredCost`. Rows ingested before the
|
|
1484
|
+
* marker existed defaulted to 0, and `INSERT ... ON CONFLICT DO UPDATE` only
|
|
1485
|
+
* refreshes them when the session is re-parsed — which the spent reingest
|
|
1486
|
+
* sentinel above will never do again. The re-parse also re-prices the
|
|
1487
|
+
* previously absent legacy `cost` charges, since `resolveStoredCost` now reads
|
|
1488
|
+
* absence as absence. Same sentinel protocol as {@link backfillReingestCosts}.
|
|
1489
|
+
*/
|
|
1490
|
+
function backfillUnpricedCosts(database: Database): void {
|
|
1491
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
|
|
1492
|
+
| { value: string }
|
|
1493
|
+
| undefined;
|
|
1494
|
+
if (!shouldResetBackfill(row?.value)) return;
|
|
1495
|
+
|
|
1496
|
+
database.run("DELETE FROM file_offsets");
|
|
1497
|
+
database
|
|
1498
|
+
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1499
|
+
.run(COST_UNPRICED_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1369
1502
|
/**
|
|
1370
1503
|
* Reclassify pre-existing `messages` rows by agent type once, after the
|
|
1371
1504
|
* `agent_type` column is added to an older database (every prior row defaulted
|
|
@@ -1492,6 +1625,7 @@ export function markSessionBackfillsComplete(): void {
|
|
|
1492
1625
|
USER_MESSAGE_LINKS_REPAIR_KEY,
|
|
1493
1626
|
PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
|
|
1494
1627
|
COST_REINGEST_BACKFILL_KEY,
|
|
1628
|
+
COST_UNPRICED_BACKFILL_KEY,
|
|
1495
1629
|
]) {
|
|
1496
1630
|
markComplete.run(key, BACKFILL_COMPLETE);
|
|
1497
1631
|
}
|
|
@@ -1841,7 +1975,9 @@ export function updateToolResults(links: ToolResultLink[]): number {
|
|
|
1841
1975
|
/**
|
|
1842
1976
|
* Shared SELECT list for tool aggregates. Real provider usage comes from the
|
|
1843
1977
|
* invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
|
|
1844
|
-
* per-tool token/cost shares stay additive across tools.
|
|
1978
|
+
* per-tool token/cost shares stay additive across tools. The unpriced share
|
|
1979
|
+
* reuses the request predicate against the joined message, whose stored cost
|
|
1980
|
+
* it attributes.
|
|
1845
1981
|
*/
|
|
1846
1982
|
const TOOL_AGGREGATE_COLUMNS = `
|
|
1847
1983
|
COUNT(*) as calls,
|
|
@@ -1851,8 +1987,7 @@ const TOOL_AGGREGATE_COLUMNS = `
|
|
|
1851
1987
|
SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
|
|
1852
1988
|
SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
|
|
1853
1989
|
SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
|
|
1854
|
-
SUM(
|
|
1855
|
-
THEN 1.0 / t.calls_in_turn ELSE 0 END) as unpriced_requests_share,
|
|
1990
|
+
SUM(${unpricedRequestSql("m.")} * 1.0 / t.calls_in_turn) as unpriced_requests_share,
|
|
1856
1991
|
MAX(t.timestamp) as last_used
|
|
1857
1992
|
`;
|
|
1858
1993
|
|
package/src/parser.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
|
|
|
14
14
|
import { getSessionsDir, isEnoent, readLines } from "@oh-my-pi/pi-utils";
|
|
15
15
|
import type {
|
|
16
16
|
AgentType,
|
|
17
|
-
|
|
17
|
+
MessageStatsInput,
|
|
18
18
|
SessionEntry,
|
|
19
19
|
SessionMessageEntry,
|
|
20
20
|
SessionModelUsageEntry,
|
|
@@ -150,6 +150,61 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Session JSONL is written by older versions and foreign producers, so a token
|
|
155
|
+
* counter is whatever was persisted, not what `Usage` declares. A non-numeric
|
|
156
|
+
* bucket (`input: "10"`) must never be parsed and must never be summed: `+`
|
|
157
|
+
* would concatenate it into the derived total and SQLite would coerce the
|
|
158
|
+
* resulting string to a different, far larger number. A non-finite one
|
|
159
|
+
* (`input: 1e999` is legal JSON) must not reach a NOT NULL column either.
|
|
160
|
+
* Malformed input counts as absent.
|
|
161
|
+
*/
|
|
162
|
+
function isFiniteCount(value: unknown): value is number {
|
|
163
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function finiteTokenCount(value: unknown): number {
|
|
167
|
+
return isFiniteCount(value) ? value : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Token-bucket view for total derivation. Persisted session payloads are
|
|
172
|
+
* outside-controlled (old versions, foreign producers), so every counter is
|
|
173
|
+
* `unknown` and validated at read time.
|
|
174
|
+
*/
|
|
175
|
+
export interface UsageBucketView {
|
|
176
|
+
totalTokens?: unknown;
|
|
177
|
+
input?: unknown;
|
|
178
|
+
output?: unknown;
|
|
179
|
+
cacheRead?: unknown;
|
|
180
|
+
cacheWrite?: unknown;
|
|
181
|
+
orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Total tokens for one usage payload, per the documented contract: the
|
|
186
|
+
* conversation buckets plus provider-reported orchestration tokens. A present
|
|
187
|
+
* finite provider total stays authoritative; a missing or malformed one
|
|
188
|
+
* (absent, string, NaN) is derived from the buckets, which would otherwise
|
|
189
|
+
* persist a zero total next to real token counts. Shared by ingest and the
|
|
190
|
+
* trace builder so stored and displayed totals cannot disagree.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveUsageTotal(usage: UsageBucketView | null | undefined): number {
|
|
193
|
+
if (typeof usage?.totalTokens === "number" && Number.isFinite(usage.totalTokens)) return usage.totalTokens;
|
|
194
|
+
if (!usage || typeof usage !== "object") return 0;
|
|
195
|
+
const orchestration =
|
|
196
|
+
usage.orchestration && typeof usage.orchestration === "object" ? usage.orchestration : undefined;
|
|
197
|
+
return (
|
|
198
|
+
finiteTokenCount(usage.input) +
|
|
199
|
+
finiteTokenCount(usage.output) +
|
|
200
|
+
finiteTokenCount(usage.cacheRead) +
|
|
201
|
+
finiteTokenCount(usage.cacheWrite) +
|
|
202
|
+
finiteTokenCount(orchestration?.input) +
|
|
203
|
+
finiteTokenCount(orchestration?.output) +
|
|
204
|
+
finiteTokenCount(orchestration?.cacheRead)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
153
208
|
/**
|
|
154
209
|
* Extract stats from an assistant message entry.
|
|
155
210
|
*
|
|
@@ -167,7 +222,7 @@ function extractStats(
|
|
|
167
222
|
entry: SessionMessageEntry,
|
|
168
223
|
currentServiceTier: ServiceTierByFamily | undefined,
|
|
169
224
|
agentType: AgentType,
|
|
170
|
-
):
|
|
225
|
+
): MessageStatsInput | null {
|
|
171
226
|
const msg = entry.message as AssistantMessage;
|
|
172
227
|
if (msg?.role !== "assistant") return null;
|
|
173
228
|
if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
|
|
@@ -189,22 +244,27 @@ function extractStats(
|
|
|
189
244
|
const tier = resolveModelServiceTier(currentServiceTier, model);
|
|
190
245
|
const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
|
|
191
246
|
const wellFormed =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const usage:
|
|
247
|
+
isFiniteCount(rawUsage.input) &&
|
|
248
|
+
isFiniteCount(rawUsage.output) &&
|
|
249
|
+
isFiniteCount(rawUsage.cacheRead) &&
|
|
250
|
+
isFiniteCount(rawUsage.cacheWrite) &&
|
|
251
|
+
isFiniteCount(rawUsage.totalTokens);
|
|
252
|
+
const usage: MessageStatsInput["usage"] =
|
|
198
253
|
wellFormed && derived === recorded
|
|
199
254
|
? (rawUsage as Usage)
|
|
200
255
|
: {
|
|
201
256
|
...rawUsage,
|
|
202
|
-
input: rawUsage.input
|
|
203
|
-
output: rawUsage.output
|
|
204
|
-
cacheRead: rawUsage.cacheRead
|
|
205
|
-
cacheWrite: rawUsage.cacheWrite
|
|
206
|
-
|
|
207
|
-
|
|
257
|
+
input: finiteTokenCount(rawUsage.input),
|
|
258
|
+
output: finiteTokenCount(rawUsage.output),
|
|
259
|
+
cacheRead: finiteTokenCount(rawUsage.cacheRead),
|
|
260
|
+
cacheWrite: finiteTokenCount(rawUsage.cacheWrite),
|
|
261
|
+
// A present finite provider total stays authoritative; a missing
|
|
262
|
+
// or malformed one (absent, string, NaN) is derived from the buckets.
|
|
263
|
+
totalTokens: resolveUsageTotal(rawUsage),
|
|
264
|
+
// An omitted `cost` must stay omitted: `resolveStoredCost` reads
|
|
265
|
+
// absence as "no recorded price" and estimates the request, while
|
|
266
|
+
// a zero would read as an explicitly free request.
|
|
267
|
+
cost: rawUsage.cost,
|
|
208
268
|
premiumRequests: derived,
|
|
209
269
|
};
|
|
210
270
|
|
|
@@ -232,7 +292,7 @@ function extractModelUsageStats(
|
|
|
232
292
|
folder: string,
|
|
233
293
|
entry: SessionModelUsageEntry,
|
|
234
294
|
agentType: AgentType,
|
|
235
|
-
):
|
|
295
|
+
): MessageStatsInput | null {
|
|
236
296
|
const timestamp = Date.parse(entry.timestamp);
|
|
237
297
|
return extractStats(
|
|
238
298
|
sessionFile,
|
|
@@ -261,7 +321,9 @@ function extractModelUsageStats(
|
|
|
261
321
|
|
|
262
322
|
/** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
|
|
263
323
|
function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
|
|
264
|
-
|
|
324
|
+
// A stored zero is the "no timestamp" sentinel, not 1970: fall through to
|
|
325
|
+
// the entry envelope so a recoverable ISO time still selects its tariff.
|
|
326
|
+
if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) return timestamp;
|
|
265
327
|
const ts = Date.parse(entry.timestamp);
|
|
266
328
|
return Number.isFinite(ts) ? ts : 0;
|
|
267
329
|
}
|
|
@@ -409,7 +471,7 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
|
|
|
409
471
|
* entries, preserving offset-based memory behavior for large sessions.
|
|
410
472
|
*/
|
|
411
473
|
export interface ParseSessionResult {
|
|
412
|
-
stats:
|
|
474
|
+
stats: MessageStatsInput[];
|
|
413
475
|
userStats: UserMessageStats[];
|
|
414
476
|
userLinks: UserMessageLink[];
|
|
415
477
|
toolCalls: ToolCallStats[];
|
|
@@ -428,7 +490,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
|
|
|
428
490
|
|
|
429
491
|
const folder = extractFolderFromPath(sessionPath);
|
|
430
492
|
const agentType = classifyAgentType(sessionPath);
|
|
431
|
-
const stats:
|
|
493
|
+
const stats: MessageStatsInput[] = [];
|
|
432
494
|
const userStats: UserMessageStats[] = [];
|
|
433
495
|
const userLinks: UserMessageLink[] = [];
|
|
434
496
|
const toolCalls: ToolCallStats[] = [];
|
package/src/shared-types.ts
CHANGED
|
@@ -484,6 +484,8 @@ export interface SessionSummary {
|
|
|
484
484
|
subagents: number;
|
|
485
485
|
totalTokens: number;
|
|
486
486
|
costTotal: number;
|
|
487
|
+
/** Requests whose zero cost is unknown spend (scheduled card, no timestamp). */
|
|
488
|
+
unpricedRequests: number;
|
|
487
489
|
models: string[];
|
|
488
490
|
}
|
|
489
491
|
|
|
@@ -569,6 +571,8 @@ export interface TraceSummary {
|
|
|
569
571
|
subagents: number;
|
|
570
572
|
totalTokens: number;
|
|
571
573
|
costTotal: number;
|
|
574
|
+
/** Model requests whose zero cost is unknown spend, not free usage. */
|
|
575
|
+
unpricedRequests: number;
|
|
572
576
|
/** Sorted totalMs desc. */
|
|
573
577
|
toolStats: TraceToolStat[];
|
|
574
578
|
}
|