@linxiraos/pi-stats 1.1.13 → 1.1.15
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 -35
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +56 -56
- package/dist/client/styles.css +46 -46
- package/dist/types/client/components/chart-shared.d.ts +21 -21
- package/dist/types/client/components/models-table-shared.d.ts +14 -14
- package/dist/types/client/data/formatters.d.ts +7 -2
- package/dist/types/client/traces/trace-colors.d.ts +1 -1
- package/dist/types/client/types.d.ts +2 -0
- package/dist/types/db.d.ts +24 -2
- package/dist/types/parser.d.ts +45 -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/aggregator.ts +4 -0
- package/src/client/app/AppLayout.tsx +1 -1
- package/src/client/app/NavRail.tsx +1 -1
- package/src/client/components/AgentTokenShare.tsx +6 -6
- package/src/client/components/chart-shared.tsx +22 -22
- package/src/client/data/formatters.ts +15 -3
- package/src/client/routes/BehaviorRoute.tsx +10 -10
- package/src/client/routes/ModelsRoute.tsx +1 -1
- package/src/client/routes/OverviewRoute.tsx +4 -4
- package/src/client/routes/ProvidersRoute.tsx +3 -3
- package/src/client/routes/TracesRoute.tsx +10 -3
- package/src/client/styles.css +54 -52
- package/src/client/traces/SummaryStrip.tsx +2 -2
- package/src/client/traces/trace-colors.ts +13 -13
- package/src/client/types.ts +2 -0
- package/src/db.ts +242 -46
- package/src/parser.ts +131 -18
- package/src/server.ts +1 -1
- package/src/shared-types.ts +4 -0
- package/src/trace.ts +52 -8
- package/src/types.ts +20 -0
package/src/parser.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
type AssistantMessage,
|
|
@@ -14,7 +15,7 @@ import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
|
|
|
14
15
|
import { getSessionsDir, isEnoent, readLines } from "@linxiraos/pi-utils";
|
|
15
16
|
import type {
|
|
16
17
|
AgentType,
|
|
17
|
-
|
|
18
|
+
MessageStatsInput,
|
|
18
19
|
SessionEntry,
|
|
19
20
|
SessionMessageEntry,
|
|
20
21
|
SessionModelUsageEntry,
|
|
@@ -62,6 +63,53 @@ export function extractFolderFromPath(sessionPath: string): string {
|
|
|
62
63
|
return projectDir.replace(/^--/, "/").replace(/--/g, "/");
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Decode a session project folder name back to its source cwd, mirroring the
|
|
68
|
+
* encoder in the coding agent's session-paths module: home-relative cwds
|
|
69
|
+
* encode as `-<segments>`, temp-relative ones as `-tmp<segments>`, and legacy
|
|
70
|
+
* absolute ones as `--<absolute>--`, with `/`, `\`, and `:` collapsed to `-`.
|
|
71
|
+
* Returns null when the name carries no decodable cwd.
|
|
72
|
+
*/
|
|
73
|
+
export function decodeProjectFolderCwd(projectDir: string): string | null {
|
|
74
|
+
if (projectDir === "-tmp" || projectDir.startsWith("-tmp-")) return os.tmpdir();
|
|
75
|
+
if (projectDir.startsWith("--")) {
|
|
76
|
+
const inner = projectDir.replace(/^--/, "").replace(/--$/, "");
|
|
77
|
+
if (!inner) return path.resolve("/");
|
|
78
|
+
// POSIX legacy names had their leading `/` stripped, so restore it;
|
|
79
|
+
// Windows names collapsed a drive letter's `:` into a dash too
|
|
80
|
+
// (`C--Users` decodes to `C:\Users`), so re-attach the colon.
|
|
81
|
+
if (path.sep === "/") return path.resolve(`/${inner.replaceAll("-", "/")}`);
|
|
82
|
+
return path.resolve(inner.replaceAll("-", "\\").replace(/^([A-Za-z])\\/, "$1:\\"));
|
|
83
|
+
}
|
|
84
|
+
if (projectDir.startsWith("-")) {
|
|
85
|
+
const inner = projectDir.replace(/^-/, "");
|
|
86
|
+
return path.resolve(os.homedir(), inner.replaceAll("-", path.sep));
|
|
87
|
+
}
|
|
88
|
+
// Folder names without the leading marker predate the encoder; no cwd is
|
|
89
|
+
// recoverable, so they are never treated as temp.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a session project folder decodes to a cwd inside the OS temp dir.
|
|
95
|
+
* Test suites spawn agents under `os.tmpdir()` and re-create their transcripts
|
|
96
|
+
* on every run; those sessions are throwaway usage that must not reach the
|
|
97
|
+
* stats DB. The `-` collapse in the encoder is lossy (a literal `-` inside a
|
|
98
|
+
* path segment is indistinguishable from a separator), which can only produce
|
|
99
|
+
* false positives here — a skipped ingestion, never a misattributed row.
|
|
100
|
+
*/
|
|
101
|
+
export function isTempProjectFolder(projectDir: string): boolean {
|
|
102
|
+
const decoded = decodeProjectFolderCwd(projectDir);
|
|
103
|
+
if (!decoded) return false;
|
|
104
|
+
const tmp = path.resolve(os.tmpdir());
|
|
105
|
+
const candidate = path.resolve(decoded);
|
|
106
|
+
// NTFS comparisons are case-insensitive; resolve() normalizes separators.
|
|
107
|
+
const caseFold = process.platform === "win32";
|
|
108
|
+
const normalizedTmp = caseFold ? tmp.toLowerCase() : tmp;
|
|
109
|
+
const normalizedCandidate = caseFold ? candidate.toLowerCase() : candidate;
|
|
110
|
+
return normalizedCandidate === normalizedTmp || normalizedCandidate.startsWith(normalizedTmp + path.sep);
|
|
111
|
+
}
|
|
112
|
+
|
|
65
113
|
/**
|
|
66
114
|
* Check if an entry is an assistant message.
|
|
67
115
|
*/
|
|
@@ -150,6 +198,61 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
|
|
|
150
198
|
};
|
|
151
199
|
}
|
|
152
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Session JSONL is written by older versions and foreign producers, so a token
|
|
203
|
+
* counter is whatever was persisted, not what `Usage` declares. A non-numeric
|
|
204
|
+
* bucket (`input: "10"`) must never be parsed and must never be summed: `+`
|
|
205
|
+
* would concatenate it into the derived total and SQLite would coerce the
|
|
206
|
+
* resulting string to a different, far larger number. A non-finite one
|
|
207
|
+
* (`input: 1e999` is legal JSON) must not reach a NOT NULL column either.
|
|
208
|
+
* Malformed input counts as absent.
|
|
209
|
+
*/
|
|
210
|
+
function isFiniteCount(value: unknown): value is number {
|
|
211
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function finiteTokenCount(value: unknown): number {
|
|
215
|
+
return isFiniteCount(value) ? value : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Token-bucket view for total derivation. Persisted session payloads are
|
|
220
|
+
* outside-controlled (old versions, foreign producers), so every counter is
|
|
221
|
+
* `unknown` and validated at read time.
|
|
222
|
+
*/
|
|
223
|
+
export interface UsageBucketView {
|
|
224
|
+
totalTokens?: unknown;
|
|
225
|
+
input?: unknown;
|
|
226
|
+
output?: unknown;
|
|
227
|
+
cacheRead?: unknown;
|
|
228
|
+
cacheWrite?: unknown;
|
|
229
|
+
orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown } | null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Total tokens for one usage payload, per the documented contract: the
|
|
234
|
+
* conversation buckets plus provider-reported orchestration tokens. A present
|
|
235
|
+
* finite provider total stays authoritative; a missing or malformed one
|
|
236
|
+
* (absent, string, NaN) is derived from the buckets, which would otherwise
|
|
237
|
+
* persist a zero total next to real token counts. Shared by ingest and the
|
|
238
|
+
* trace builder so stored and displayed totals cannot disagree.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveUsageTotal(usage: UsageBucketView | null | undefined): number {
|
|
241
|
+
if (typeof usage?.totalTokens === "number" && Number.isFinite(usage.totalTokens)) return usage.totalTokens;
|
|
242
|
+
if (!usage || typeof usage !== "object") return 0;
|
|
243
|
+
const orchestration =
|
|
244
|
+
usage.orchestration && typeof usage.orchestration === "object" ? usage.orchestration : undefined;
|
|
245
|
+
return (
|
|
246
|
+
finiteTokenCount(usage.input) +
|
|
247
|
+
finiteTokenCount(usage.output) +
|
|
248
|
+
finiteTokenCount(usage.cacheRead) +
|
|
249
|
+
finiteTokenCount(usage.cacheWrite) +
|
|
250
|
+
finiteTokenCount(orchestration?.input) +
|
|
251
|
+
finiteTokenCount(orchestration?.output) +
|
|
252
|
+
finiteTokenCount(orchestration?.cacheRead)
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
153
256
|
/**
|
|
154
257
|
* Extract stats from an assistant message entry.
|
|
155
258
|
*
|
|
@@ -167,7 +270,7 @@ function extractStats(
|
|
|
167
270
|
entry: SessionMessageEntry,
|
|
168
271
|
currentServiceTier: ServiceTierByFamily | undefined,
|
|
169
272
|
agentType: AgentType,
|
|
170
|
-
):
|
|
273
|
+
): MessageStatsInput | null {
|
|
171
274
|
const msg = entry.message as AssistantMessage;
|
|
172
275
|
if (msg?.role !== "assistant") return null;
|
|
173
276
|
if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
|
|
@@ -189,22 +292,27 @@ function extractStats(
|
|
|
189
292
|
const tier = resolveModelServiceTier(currentServiceTier, model);
|
|
190
293
|
const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
|
|
191
294
|
const wellFormed =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const usage:
|
|
295
|
+
isFiniteCount(rawUsage.input) &&
|
|
296
|
+
isFiniteCount(rawUsage.output) &&
|
|
297
|
+
isFiniteCount(rawUsage.cacheRead) &&
|
|
298
|
+
isFiniteCount(rawUsage.cacheWrite) &&
|
|
299
|
+
isFiniteCount(rawUsage.totalTokens);
|
|
300
|
+
const usage: MessageStatsInput["usage"] =
|
|
198
301
|
wellFormed && derived === recorded
|
|
199
302
|
? (rawUsage as Usage)
|
|
200
303
|
: {
|
|
201
304
|
...rawUsage,
|
|
202
|
-
input: rawUsage.input
|
|
203
|
-
output: rawUsage.output
|
|
204
|
-
cacheRead: rawUsage.cacheRead
|
|
205
|
-
cacheWrite: rawUsage.cacheWrite
|
|
206
|
-
|
|
207
|
-
|
|
305
|
+
input: finiteTokenCount(rawUsage.input),
|
|
306
|
+
output: finiteTokenCount(rawUsage.output),
|
|
307
|
+
cacheRead: finiteTokenCount(rawUsage.cacheRead),
|
|
308
|
+
cacheWrite: finiteTokenCount(rawUsage.cacheWrite),
|
|
309
|
+
// A present finite provider total stays authoritative; a missing
|
|
310
|
+
// or malformed one (absent, string, NaN) is derived from the buckets.
|
|
311
|
+
totalTokens: resolveUsageTotal(rawUsage),
|
|
312
|
+
// An omitted `cost` must stay omitted: `resolveStoredCost` reads
|
|
313
|
+
// absence as "no recorded price" and estimates the request, while
|
|
314
|
+
// a zero would read as an explicitly free request.
|
|
315
|
+
cost: rawUsage.cost,
|
|
208
316
|
premiumRequests: derived,
|
|
209
317
|
};
|
|
210
318
|
|
|
@@ -232,7 +340,7 @@ function extractModelUsageStats(
|
|
|
232
340
|
folder: string,
|
|
233
341
|
entry: SessionModelUsageEntry,
|
|
234
342
|
agentType: AgentType,
|
|
235
|
-
):
|
|
343
|
+
): MessageStatsInput | null {
|
|
236
344
|
const timestamp = Date.parse(entry.timestamp);
|
|
237
345
|
return extractStats(
|
|
238
346
|
sessionFile,
|
|
@@ -261,7 +369,9 @@ function extractModelUsageStats(
|
|
|
261
369
|
|
|
262
370
|
/** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
|
|
263
371
|
function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
|
|
264
|
-
|
|
372
|
+
// A stored zero is the "no timestamp" sentinel, not 1970: fall through to
|
|
373
|
+
// the entry envelope so a recoverable ISO time still selects its tariff.
|
|
374
|
+
if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) return timestamp;
|
|
265
375
|
const ts = Date.parse(entry.timestamp);
|
|
266
376
|
return Number.isFinite(ts) ? ts : 0;
|
|
267
377
|
}
|
|
@@ -409,7 +519,7 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
|
|
|
409
519
|
* entries, preserving offset-based memory behavior for large sessions.
|
|
410
520
|
*/
|
|
411
521
|
export interface ParseSessionResult {
|
|
412
|
-
stats:
|
|
522
|
+
stats: MessageStatsInput[];
|
|
413
523
|
userStats: UserMessageStats[];
|
|
414
524
|
userLinks: UserMessageLink[];
|
|
415
525
|
toolCalls: ToolCallStats[];
|
|
@@ -428,7 +538,7 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
|
|
|
428
538
|
|
|
429
539
|
const folder = extractFolderFromPath(sessionPath);
|
|
430
540
|
const agentType = classifyAgentType(sessionPath);
|
|
431
|
-
const stats:
|
|
541
|
+
const stats: MessageStatsInput[] = [];
|
|
432
542
|
const userStats: UserMessageStats[] = [];
|
|
433
543
|
const userLinks: UserMessageLink[] = [];
|
|
434
544
|
const toolCalls: ToolCallStats[] = [];
|
|
@@ -526,6 +636,9 @@ export async function listAllSessionFiles(): Promise<string[]> {
|
|
|
526
636
|
const allFiles: string[] = [];
|
|
527
637
|
|
|
528
638
|
for (const folder of folders) {
|
|
639
|
+
// Skip at enumeration: temp-cwd project folders re-create their
|
|
640
|
+
// transcripts on every test run and must never reach the parser or DB.
|
|
641
|
+
if (isTempProjectFolder(path.basename(folder))) continue;
|
|
529
642
|
const files = await listSessionFiles(folder);
|
|
530
643
|
allFiles.push(...files);
|
|
531
644
|
}
|
package/src/server.ts
CHANGED
|
@@ -180,7 +180,7 @@ const ensureClientBuild = async () => {
|
|
|
180
180
|
<head>
|
|
181
181
|
<meta charset="UTF-8">
|
|
182
182
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
183
|
-
<title>
|
|
183
|
+
<title>Zeta Stats</title>
|
|
184
184
|
<link rel="stylesheet" href="styles.css">
|
|
185
185
|
</head>
|
|
186
186
|
<body>
|
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
|
}
|
package/src/trace.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs/promises";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { getBundledModel, type GeneratedProvider } from "@linxiraos/pi-catalog/models";
|
|
14
15
|
import { getSessionsDir, isEnoent } from "@linxiraos/pi-utils";
|
|
15
|
-
import { getSessionRollups, getToolCallCountsBySession } from "./db";
|
|
16
|
-
import { extractFolderFromPath, parseAllSessionEntries } from "./parser";
|
|
16
|
+
import { getSessionRollups, getToolCallCountsBySession, isScheduledCatalogModel } from "./db";
|
|
17
|
+
import { extractFolderFromPath, parseAllSessionEntries, resolveUsageTotal } from "./parser";
|
|
17
18
|
import type {
|
|
18
19
|
SessionEntry,
|
|
19
20
|
SessionSummary,
|
|
@@ -32,7 +33,7 @@ export class TracePathError extends Error {}
|
|
|
32
33
|
* span assembly changes so browsers don't revalidate stale cached bodies
|
|
33
34
|
* against an unchanged transcript mtime.
|
|
34
35
|
*/
|
|
35
|
-
export const TRACE_ETAG_VERSION =
|
|
36
|
+
export const TRACE_ETAG_VERSION = 3;
|
|
36
37
|
|
|
37
38
|
const MAX_TRACK_DEPTH = 6;
|
|
38
39
|
const LABEL_MAX = 80;
|
|
@@ -84,7 +85,15 @@ interface MessageView {
|
|
|
84
85
|
completedAt?: number;
|
|
85
86
|
duration?: number;
|
|
86
87
|
ttft?: number;
|
|
87
|
-
usage?: {
|
|
88
|
+
usage?: {
|
|
89
|
+
input?: unknown;
|
|
90
|
+
output?: unknown;
|
|
91
|
+
cacheRead?: unknown;
|
|
92
|
+
cacheWrite?: unknown;
|
|
93
|
+
totalTokens?: unknown;
|
|
94
|
+
orchestration?: { input?: unknown; output?: unknown; cacheRead?: unknown };
|
|
95
|
+
cost?: { total?: unknown };
|
|
96
|
+
};
|
|
88
97
|
model?: string;
|
|
89
98
|
provider?: string;
|
|
90
99
|
stopReason?: string;
|
|
@@ -115,13 +124,14 @@ interface TaskResultFact {
|
|
|
115
124
|
resultEntryId: string;
|
|
116
125
|
toolSpanEnd: number;
|
|
117
126
|
}
|
|
118
|
-
|
|
119
127
|
/** Everything one transcript contributes before cross-track assembly. */
|
|
120
128
|
interface TrackScan {
|
|
121
129
|
track: TraceTrack;
|
|
122
130
|
taskResults: TaskResultFact[];
|
|
123
131
|
requests: number;
|
|
124
132
|
toolCalls: number;
|
|
133
|
+
/** Model requests whose zero cost is unknown spend (mirrors db's unpriced predicate). */
|
|
134
|
+
unpricedRequests: number;
|
|
125
135
|
cwd: string | null;
|
|
126
136
|
title: string | null;
|
|
127
137
|
/** Session-header envelope timestamp (ms), for spanless traces. */
|
|
@@ -284,6 +294,7 @@ function scanTranscript(
|
|
|
284
294
|
let initModel: string | null = null;
|
|
285
295
|
let firstAssistantModel: string | null = null;
|
|
286
296
|
let requests = 0;
|
|
297
|
+
let unpricedRequests = 0;
|
|
287
298
|
let lastChainTs = 0;
|
|
288
299
|
|
|
289
300
|
// Title slot and session header may sit outside the id chain — scan all entries.
|
|
@@ -358,6 +369,32 @@ function scanTranscript(
|
|
|
358
369
|
|
|
359
370
|
if (msg.role === "assistant") {
|
|
360
371
|
requests++;
|
|
372
|
+
// Mirror the db's unpriced predicate so the trace headline agrees
|
|
373
|
+
// with Costs/Recent Requests: a zero that is unknown spend, not free.
|
|
374
|
+
// Counted before the span-timing `continue` below so a fully
|
|
375
|
+
// dateless entry still surfaces as N/A rather than vanishing.
|
|
376
|
+
// Derive a missing total from the buckets via the shared ingest
|
|
377
|
+
// helper, so a legacy entry with tokens but no total still
|
|
378
|
+
// classifies as unpriced.
|
|
379
|
+
const totalTokens = resolveUsageTotal(msg.usage);
|
|
380
|
+
const costObj = msg.usage?.cost;
|
|
381
|
+
const costTotal =
|
|
382
|
+
typeof costObj?.total === "number" && Number.isFinite(costObj.total) ? costObj.total : undefined;
|
|
383
|
+
if (totalTokens > 0 && (costTotal === undefined || costTotal === 0)) {
|
|
384
|
+
const provider = typeof msg.provider === "string" ? msg.provider : "";
|
|
385
|
+
const model = typeof msg.model === "string" ? msg.model : "";
|
|
386
|
+
if (provider === "xai-oauth" && model) {
|
|
387
|
+
const ref = getBundledModel("xai" as GeneratedProvider, model)?.cost;
|
|
388
|
+
if (!(ref && (ref.input !== 0 || ref.output !== 0 || ref.cacheRead !== 0 || ref.cacheWrite !== 0))) {
|
|
389
|
+
unpricedRequests++;
|
|
390
|
+
}
|
|
391
|
+
} else if (costObj == null && provider && model) {
|
|
392
|
+
const hasTimestamp =
|
|
393
|
+
(typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) && msg.timestamp > 0) ||
|
|
394
|
+
envelopeTs !== undefined;
|
|
395
|
+
if (!hasTimestamp && isScheduledCatalogModel(provider, model)) unpricedRequests++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
361
398
|
let start = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : undefined;
|
|
362
399
|
let end =
|
|
363
400
|
typeof msg.completedAt === "number" && Number.isFinite(msg.completedAt)
|
|
@@ -386,12 +423,12 @@ function scanTranscript(
|
|
|
386
423
|
if (detail) span.detail = detail;
|
|
387
424
|
if (entry.id) span.entryId = entry.id;
|
|
388
425
|
if (typeof msg.model === "string") span.model = msg.model;
|
|
389
|
-
if (
|
|
390
|
-
|
|
426
|
+
if (totalTokens > 0) span.tokens = totalTokens;
|
|
427
|
+
|
|
428
|
+
if (costTotal !== undefined) span.cost = costTotal;
|
|
391
429
|
if (typeof msg.ttft === "number") span.ttft = msg.ttft;
|
|
392
430
|
if (msg.stopReason === "error" || msg.errorMessage) span.isError = true;
|
|
393
431
|
spans.push(span);
|
|
394
|
-
|
|
395
432
|
if (Array.isArray(msg.content)) {
|
|
396
433
|
for (const block of msg.content) {
|
|
397
434
|
if (!block || typeof block !== "object" || !("type" in block) || block.type !== "toolCall") continue;
|
|
@@ -572,6 +609,7 @@ function scanTranscript(
|
|
|
572
609
|
taskResults,
|
|
573
610
|
requests,
|
|
574
611
|
toolCalls: pendingTools.length,
|
|
612
|
+
unpricedRequests,
|
|
575
613
|
cwd,
|
|
576
614
|
title: slotTitle ?? headerTitle,
|
|
577
615
|
headerTs,
|
|
@@ -722,11 +760,13 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
|
|
|
722
760
|
let requests = 0;
|
|
723
761
|
let totalTokens = 0;
|
|
724
762
|
let costTotal = 0;
|
|
763
|
+
let unpricedRequests = 0;
|
|
725
764
|
const toolStats = new Map<string, TraceToolStat>();
|
|
726
765
|
|
|
727
766
|
for (const scan of scans) {
|
|
728
767
|
requests += scan.requests;
|
|
729
768
|
toolCalls += scan.toolCalls;
|
|
769
|
+
unpricedRequests += scan.unpricedRequests;
|
|
730
770
|
for (const span of scan.track.spans) {
|
|
731
771
|
intervals.push([span.start, span.end]);
|
|
732
772
|
if (startedAt === undefined || span.start < startedAt) startedAt = span.start;
|
|
@@ -779,6 +819,7 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
|
|
|
779
819
|
subagents: tracks.length - 1,
|
|
780
820
|
totalTokens,
|
|
781
821
|
costTotal,
|
|
822
|
+
unpricedRequests,
|
|
782
823
|
toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
|
|
783
824
|
};
|
|
784
825
|
|
|
@@ -936,6 +977,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
|
|
|
936
977
|
subagents: 0,
|
|
937
978
|
totalTokens: 0,
|
|
938
979
|
costTotal: 0,
|
|
980
|
+
unpricedRequests: 0,
|
|
939
981
|
models: [],
|
|
940
982
|
modelSet: new Set(),
|
|
941
983
|
};
|
|
@@ -948,6 +990,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
|
|
|
948
990
|
if (row.endedAt > fold.endedAt) fold.endedAt = row.endedAt;
|
|
949
991
|
fold.totalTokens += row.totalTokens ?? 0;
|
|
950
992
|
fold.costTotal += row.costTotal ?? 0;
|
|
993
|
+
fold.unpricedRequests += row.unpricedRequests ?? 0;
|
|
951
994
|
if (row.models) {
|
|
952
995
|
for (const model of row.models.split(",")) {
|
|
953
996
|
if (model) fold.modelSet.add(model);
|
|
@@ -975,6 +1018,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
|
|
|
975
1018
|
subagents: 0,
|
|
976
1019
|
totalTokens: 0,
|
|
977
1020
|
costTotal: 0,
|
|
1021
|
+
unpricedRequests: 0,
|
|
978
1022
|
models: [],
|
|
979
1023
|
modelSet: new Set(),
|
|
980
1024
|
});
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,26 @@ export interface MessageStats {
|
|
|
35
35
|
usage: Usage;
|
|
36
36
|
/** Which agent produced this message (main agent, task subagent, advisor) */
|
|
37
37
|
agentType: AgentType;
|
|
38
|
+
/**
|
|
39
|
+
* Ingest refused to price this request: a scheduled (time-based) card with no
|
|
40
|
+
* recoverable request timestamp, so `usage.cost.total` of 0 is unknown spend
|
|
41
|
+
* rather than a free request. Always written by `rowToMessageStats`; optional
|
|
42
|
+
* only because session fixtures that stand in for ingest input are typed as
|
|
43
|
+
* `MessageStats` too.
|
|
44
|
+
*/
|
|
45
|
+
costUnpriced?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Session-recorded token usage before pricing: counters are coerced to numbers
|
|
50
|
+
* because their columns are NOT NULL, but `cost` is only present when the
|
|
51
|
+
* session entry actually recorded one. Absence is meaningful — `resolveStoredCost`
|
|
52
|
+
* estimates a request with no recorded price, whereas a recorded zero is a real
|
|
53
|
+
* charge that scheduled cards freeze. `costUnpriced` is omitted because it is a
|
|
54
|
+
* decision of the ingest path, not something the parser or a caller supplies.
|
|
55
|
+
*/
|
|
56
|
+
export interface MessageStatsInput extends Omit<MessageStats, "usage" | "costUnpriced"> {
|
|
57
|
+
usage: Omit<Usage, "cost"> & { cost?: Partial<Usage["cost"]> };
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
/**
|