claude-agent-ledger 0.4.1 → 0.7.0
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 +95 -0
- package/README.md +17 -1
- package/bin/agent-ledger.ts +194 -57
- package/bun.lock +5 -0
- package/package.json +2 -1
- package/src/aggregator.ts +173 -71
- package/src/explain.ts +207 -0
- package/src/parser.ts +110 -32
- package/src/tree.ts +107 -0
- package/src/types.ts +26 -8
- package/test/property.test.ts +188 -0
package/src/parser.ts
CHANGED
|
@@ -5,6 +5,9 @@ import type { SessionTurn, TokenUsage } from './types.ts';
|
|
|
5
5
|
|
|
6
6
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
7
7
|
|
|
8
|
+
// Limit concurrent file reads so we don't exhaust fd on huge setups.
|
|
9
|
+
const CONCURRENCY = 8;
|
|
10
|
+
|
|
8
11
|
export interface ParseOptions {
|
|
9
12
|
from?: Date;
|
|
10
13
|
to?: Date;
|
|
@@ -15,16 +18,9 @@ interface LogFile {
|
|
|
15
18
|
path: string;
|
|
16
19
|
projectId: string;
|
|
17
20
|
subagentType: string | undefined;
|
|
21
|
+
parentSessionId?: string | undefined; // for subagent logs, the parent session
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
// Walk the projects directory and yield every JSONL log.
|
|
21
|
-
// Real Claude Code layout (as of 2026-04):
|
|
22
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>.jsonl (main session)
|
|
23
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.jsonl (subagent)
|
|
24
|
-
// ~/.claude/projects/<encoded-project>/<session-uuid>/subagents/agent-<hash>.meta.json
|
|
25
|
-
//
|
|
26
|
-
// The .meta.json next to each subagent .jsonl carries { "agentType": "...", "description": "..." }.
|
|
27
|
-
// We read it to attribute the subagent by its real name (e.g. "Reality Checker") rather than an opaque hash.
|
|
28
24
|
export async function* findSessionLogs(
|
|
29
25
|
projectsDir: string = CLAUDE_PROJECTS_DIR,
|
|
30
26
|
): AsyncGenerator<LogFile> {
|
|
@@ -34,11 +30,22 @@ export async function* findSessionLogs(
|
|
|
34
30
|
} catch {
|
|
35
31
|
return;
|
|
36
32
|
}
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
// Parallelize the project-level walks; collect LogFiles via streaming queue.
|
|
34
|
+
const walkers = projects.map((project) =>
|
|
35
|
+
collectWalk(join(projectsDir, project), project),
|
|
36
|
+
);
|
|
37
|
+
const settled = await Promise.all(walkers);
|
|
38
|
+
for (const list of settled) {
|
|
39
|
+
for (const lf of list) yield lf;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
async function collectWalk(dir: string, projectId: string): Promise<LogFile[]> {
|
|
44
|
+
const out: LogFile[] = [];
|
|
45
|
+
for await (const lf of walkForJsonl(dir, projectId)) out.push(lf);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<LogFile> {
|
|
43
50
|
let entries: string[] = [];
|
|
44
51
|
try {
|
|
@@ -46,6 +53,9 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
|
|
|
46
53
|
} catch {
|
|
47
54
|
return;
|
|
48
55
|
}
|
|
56
|
+
// If this directory IS named 'subagents', its parent session is one level up.
|
|
57
|
+
// e.g. .../<session-uuid>/subagents/agent-XXX.jsonl → parent = <session-uuid>
|
|
58
|
+
const parentSession = basename(dir) === 'subagents' ? basename(dirname(dir)) : undefined;
|
|
49
59
|
for (const name of entries) {
|
|
50
60
|
if (name.startsWith('.') || name === 'memory') continue;
|
|
51
61
|
const full = join(dir, name);
|
|
@@ -60,13 +70,18 @@ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<Log
|
|
|
60
70
|
continue;
|
|
61
71
|
}
|
|
62
72
|
if (name.endsWith('.jsonl')) {
|
|
63
|
-
|
|
73
|
+
const subagentType = await resolveSubagentType(full);
|
|
74
|
+
yield {
|
|
75
|
+
path: full,
|
|
76
|
+
projectId,
|
|
77
|
+
subagentType,
|
|
78
|
+
parentSessionId: parentSession,
|
|
79
|
+
};
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
async function resolveSubagentType(jsonlPath: string): Promise<string | undefined> {
|
|
69
|
-
// Subagent logs live under `.../subagents/agent-<hash>.jsonl` with a sibling `.meta.json`.
|
|
70
85
|
if (basename(dirname(jsonlPath)) !== 'subagents') return undefined;
|
|
71
86
|
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json');
|
|
72
87
|
try {
|
|
@@ -78,26 +93,51 @@ async function resolveSubagentType(jsonlPath: string): Promise<string | undefine
|
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
//
|
|
96
|
+
// Streaming line reader — does NOT buffer the whole file.
|
|
97
|
+
// Handles partial lines across chunk boundaries. Skips malformed JSON.
|
|
82
98
|
export async function* parseFile(
|
|
83
99
|
path: string,
|
|
84
100
|
subagentType?: string,
|
|
85
101
|
projectId?: string,
|
|
102
|
+
parentSessionId?: string,
|
|
86
103
|
): AsyncGenerator<SessionTurn> {
|
|
87
|
-
const file = Bun.file(path);
|
|
88
|
-
const text = await file.text();
|
|
89
104
|
const sessionId = basename(path).replace('.jsonl', '');
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const stream = Bun.file(path).stream();
|
|
106
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
107
|
+
let buffer = '';
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
110
|
+
let nl: number;
|
|
111
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
112
|
+
const line = buffer.slice(0, nl);
|
|
113
|
+
buffer = buffer.slice(nl + 1);
|
|
114
|
+
if (!line.trim()) continue;
|
|
115
|
+
const turn = tryParseLine(line, sessionId, subagentType, projectId, parentSessionId);
|
|
116
|
+
if (turn) yield turn;
|
|
98
117
|
}
|
|
99
|
-
yield extractTurn(obj, sessionId, subagentType, projectId);
|
|
100
118
|
}
|
|
119
|
+
// Flush any trailing partial line.
|
|
120
|
+
buffer += decoder.decode();
|
|
121
|
+
if (buffer.trim()) {
|
|
122
|
+
const turn = tryParseLine(buffer, sessionId, subagentType, projectId, parentSessionId);
|
|
123
|
+
if (turn) yield turn;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tryParseLine(
|
|
128
|
+
line: string,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
subagentType: string | undefined,
|
|
131
|
+
projectId: string | undefined,
|
|
132
|
+
parentSessionId: string | undefined,
|
|
133
|
+
): SessionTurn | undefined {
|
|
134
|
+
let obj: Record<string, unknown>;
|
|
135
|
+
try {
|
|
136
|
+
obj = JSON.parse(line);
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return extractTurn(obj, sessionId, subagentType, projectId, parentSessionId);
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
function extractTurn(
|
|
@@ -105,14 +145,29 @@ function extractTurn(
|
|
|
105
145
|
sessionId: string,
|
|
106
146
|
subagentType: string | undefined,
|
|
107
147
|
projectId: string | undefined,
|
|
148
|
+
parentSessionId: string | undefined,
|
|
108
149
|
): SessionTurn {
|
|
109
150
|
const msg = (obj.message ?? {}) as Record<string, unknown>;
|
|
110
151
|
const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
|
|
111
152
|
const model = (msg.model ?? undefined) as string | undefined;
|
|
112
|
-
// If the raw turn carries its own subagentType (our fixture format), prefer it;
|
|
113
|
-
// otherwise fall back to the directory-derived one.
|
|
114
153
|
const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
|
|
115
154
|
const perTurnProject = ((obj.projectId as string) ?? undefined) || undefined;
|
|
155
|
+
const perTurnParent = ((obj.parentSessionId as string) ?? undefined) || undefined;
|
|
156
|
+
|
|
157
|
+
// Extract tool_use blocks from the assistant message content (if any).
|
|
158
|
+
// Used by the --by tool aggregator and subagent-graph attribution.
|
|
159
|
+
const toolUses: string[] = [];
|
|
160
|
+
const content = msg.content;
|
|
161
|
+
if (Array.isArray(content)) {
|
|
162
|
+
for (const block of content) {
|
|
163
|
+
if (block && typeof block === 'object') {
|
|
164
|
+
const b = block as Record<string, unknown>;
|
|
165
|
+
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
|
166
|
+
toolUses.push(b.name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
116
171
|
|
|
117
172
|
return {
|
|
118
173
|
type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
|
|
@@ -120,20 +175,43 @@ function extractTurn(
|
|
|
120
175
|
sessionId,
|
|
121
176
|
projectId: perTurnProject ?? projectId,
|
|
122
177
|
subagentType: perTurnSubagent ?? subagentType,
|
|
178
|
+
parentSessionId: perTurnParent ?? parentSessionId,
|
|
123
179
|
model,
|
|
124
180
|
usage,
|
|
181
|
+
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
|
125
182
|
raw: obj,
|
|
126
183
|
};
|
|
127
184
|
}
|
|
128
185
|
|
|
129
|
-
//
|
|
186
|
+
// Parallelized, streaming. Does not materialize all turns — caller iterates
|
|
187
|
+
// once and dispatches to aggregators. Reads up to CONCURRENCY files at a time.
|
|
130
188
|
export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
|
|
131
189
|
const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
190
|
+
const files: LogFile[] = [];
|
|
191
|
+
for await (const f of findSessionLogs(dir)) files.push(f);
|
|
192
|
+
|
|
193
|
+
// Fan out file reads in batches of CONCURRENCY, yield turns in arrival order.
|
|
194
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
195
|
+
const batch = files.slice(i, i + CONCURRENCY);
|
|
196
|
+
const streams = batch.map((f) =>
|
|
197
|
+
collectBatch(parseFile(f.path, f.subagentType, f.projectId, f.parentSessionId), opts),
|
|
198
|
+
);
|
|
199
|
+
const results = await Promise.all(streams);
|
|
200
|
+
for (const turns of results) {
|
|
201
|
+
for (const t of turns) yield t;
|
|
137
202
|
}
|
|
138
203
|
}
|
|
139
204
|
}
|
|
205
|
+
|
|
206
|
+
async function collectBatch(
|
|
207
|
+
gen: AsyncGenerator<SessionTurn>,
|
|
208
|
+
opts: ParseOptions,
|
|
209
|
+
): Promise<SessionTurn[]> {
|
|
210
|
+
const out: SessionTurn[] = [];
|
|
211
|
+
for await (const t of gen) {
|
|
212
|
+
if (opts.from && new Date(t.timestamp) < opts.from) continue;
|
|
213
|
+
if (opts.to && new Date(t.timestamp) > opts.to) continue;
|
|
214
|
+
out.push(t);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ASCII tree renderer for subagent graph (the MOAT feature).
|
|
2
|
+
// Helicone/Langfuse observe API calls; only Claude Code knows that
|
|
3
|
+
// orchestrator → 3×researcher + 6×swift-dev. We render that tree.
|
|
4
|
+
|
|
5
|
+
import type { SubagentTreeNode } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const fmtUsd = (n: number): string => {
|
|
8
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
9
|
+
return '$' + n.toFixed(2);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const dim = (s: string): string =>
|
|
13
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[2m${s}\x1b[0m`;
|
|
14
|
+
|
|
15
|
+
const bold = (s: string): string =>
|
|
16
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[1m${s}\x1b[0m`;
|
|
17
|
+
|
|
18
|
+
const amber = (s: string): string =>
|
|
19
|
+
process.env.NO_COLOR || !process.stdout.isTTY ? s : `\x1b[38;5;215m${s}\x1b[0m`;
|
|
20
|
+
|
|
21
|
+
export function formatTree(roots: SubagentTreeNode[], totalCost: number): string {
|
|
22
|
+
if (roots.length === 0) return dim('No subagent graphs found in this period.');
|
|
23
|
+
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
lines.push(
|
|
26
|
+
bold('Subagent graphs') +
|
|
27
|
+
dim(' · top ') +
|
|
28
|
+
roots.length +
|
|
29
|
+
dim(' orchestrators by total cost · grand-total ') +
|
|
30
|
+
amber(fmtUsd(totalCost)),
|
|
31
|
+
);
|
|
32
|
+
lines.push('');
|
|
33
|
+
for (const root of roots) {
|
|
34
|
+
renderNode(root, '', true, lines, totalCost, true);
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderNode(
|
|
41
|
+
node: SubagentTreeNode,
|
|
42
|
+
prefix: string,
|
|
43
|
+
isLast: boolean,
|
|
44
|
+
lines: string[],
|
|
45
|
+
grandTotal: number,
|
|
46
|
+
isRoot: boolean,
|
|
47
|
+
): void {
|
|
48
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
49
|
+
const sessionShort = dim(node.sessionId.slice(0, 8));
|
|
50
|
+
const pct = grandTotal > 0 ? ((node.totalCost / grandTotal) * 100).toFixed(1) + '%' : '—';
|
|
51
|
+
const turns = dim(`${node.turnCount} turns`);
|
|
52
|
+
|
|
53
|
+
if (isRoot) {
|
|
54
|
+
const childCount = countDescendants(node);
|
|
55
|
+
const childInfo = childCount > 0 ? dim(` (${childCount} children)`) : '';
|
|
56
|
+
lines.push(
|
|
57
|
+
`${bold(node.label)} ${sessionShort}${childInfo} ${amber(fmtUsd(node.totalCost))} ${dim(`(${pct})`)}`,
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
const selfNote =
|
|
61
|
+
node.children.length > 0
|
|
62
|
+
? dim(` self ${fmtUsd(node.selfCost)}`)
|
|
63
|
+
: '';
|
|
64
|
+
lines.push(
|
|
65
|
+
`${dim(prefix)}${connector}${node.label} ${sessionShort} ${amber(fmtUsd(node.totalCost))}${selfNote} ${turns}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const childPrefix = prefix + (isRoot ? '' : isLast ? ' ' : '│ ');
|
|
70
|
+
node.children.forEach((child, idx) => {
|
|
71
|
+
const last = idx === node.children.length - 1;
|
|
72
|
+
renderNode(child, childPrefix, last, lines, grandTotal, false);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countDescendants(node: SubagentTreeNode): number {
|
|
77
|
+
let n = node.children.length;
|
|
78
|
+
for (const c of node.children) n += countDescendants(c);
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Markdown variant — same data, GH-friendly nested list.
|
|
83
|
+
export function formatTreeMarkdown(roots: SubagentTreeNode[], totalCost: number): string {
|
|
84
|
+
const lines: string[] = ['# Subagent graphs', ''];
|
|
85
|
+
lines.push(`Top ${roots.length} orchestrators by total cost. Grand total: **${fmtUsd(totalCost)}**.`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
for (const root of roots) {
|
|
88
|
+
const childCount = countDescendants(root);
|
|
89
|
+
lines.push(
|
|
90
|
+
`## ${root.label} \`${root.sessionId.slice(0, 8)}\` — ${fmtUsd(root.totalCost)} (${childCount} children, ${root.turnCount} turns)`,
|
|
91
|
+
);
|
|
92
|
+
mdNode(root, lines, 0);
|
|
93
|
+
lines.push('');
|
|
94
|
+
}
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mdNode(node: SubagentTreeNode, lines: string[], depth: number): void {
|
|
99
|
+
for (const child of node.children) {
|
|
100
|
+
const indent = ' '.repeat(depth);
|
|
101
|
+
const self = child.children.length > 0 ? ` _self ${fmtUsd(child.selfCost)}_` : '';
|
|
102
|
+
lines.push(
|
|
103
|
+
`${indent}- **${child.label}** \`${child.sessionId.slice(0, 8)}\` — ${fmtUsd(child.totalCost)} (${child.turnCount} turns)${self}`,
|
|
104
|
+
);
|
|
105
|
+
mdNode(child, lines, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Known model ids — kept narrow for documentation. Use `string` at the boundary.
|
|
2
|
+
export const KNOWN_MODELS = [
|
|
3
|
+
'claude-opus-4-7',
|
|
4
|
+
'claude-sonnet-4-6',
|
|
5
|
+
'claude-haiku-4-5-20251001',
|
|
6
|
+
'claude-opus-4-6',
|
|
7
|
+
'claude-opus-4-5',
|
|
8
|
+
'claude-sonnet-4-5',
|
|
9
|
+
'claude-haiku-4-0',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type ModelId = string;
|
|
6
13
|
|
|
7
14
|
export type TurnType = 'user' | 'assistant' | 'summary' | 'system';
|
|
8
15
|
|
|
@@ -19,12 +26,9 @@ export interface ServerToolUse {
|
|
|
19
26
|
export interface TokenUsage {
|
|
20
27
|
input_tokens: number;
|
|
21
28
|
output_tokens: number;
|
|
22
|
-
// Legacy flat counter. Real logs also carry `cache_creation` with TTL split; if present,
|
|
23
|
-
// prefer that for accurate pricing.
|
|
24
29
|
cache_creation_input_tokens?: number;
|
|
25
30
|
cache_read_input_tokens?: number;
|
|
26
31
|
cache_creation?: CacheCreationDetail;
|
|
27
|
-
// Anthropic server-side tool use (web_search, web_fetch). Priced per request, not per token.
|
|
28
32
|
server_tool_use?: ServerToolUse;
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -34,8 +38,10 @@ export interface SessionTurn {
|
|
|
34
38
|
sessionId: string;
|
|
35
39
|
projectId?: string | undefined;
|
|
36
40
|
subagentType?: string | undefined;
|
|
41
|
+
parentSessionId?: string | undefined;
|
|
37
42
|
model?: ModelId | undefined;
|
|
38
43
|
usage?: TokenUsage | undefined;
|
|
44
|
+
toolUses?: string[] | undefined; // tool names invoked in this assistant turn
|
|
39
45
|
raw: unknown;
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -67,3 +73,15 @@ export interface LedgerReport {
|
|
|
67
73
|
rows: AggregatedRow[];
|
|
68
74
|
total: AggregatedRow;
|
|
69
75
|
}
|
|
76
|
+
|
|
77
|
+
// Subagent-graph node — the MOAT feature.
|
|
78
|
+
// Represents one parent session's tree-rooted cost attribution:
|
|
79
|
+
// the parent's own cost + all child subagent costs, recursively.
|
|
80
|
+
export interface SubagentTreeNode {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
label: string; // subagentType or '(orchestrator)'
|
|
83
|
+
selfCost: number;
|
|
84
|
+
totalCost: number; // self + children
|
|
85
|
+
children: SubagentTreeNode[];
|
|
86
|
+
turnCount: number;
|
|
87
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Property tests — invariants that must hold for ANY input, not just fixtures.
|
|
2
|
+
// (Backend audit recommendation: cover pricing math + aggregator math.)
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import * as fc from 'fast-check';
|
|
6
|
+
import { Aggregator, SubagentGraph } from '../src/aggregator.ts';
|
|
7
|
+
import { costFor } from '../src/pricing.ts';
|
|
8
|
+
import type { SessionTurn, TokenUsage } from '../src/types.ts';
|
|
9
|
+
|
|
10
|
+
// Bounded-positive token counter — Anthropic emits non-negative integers.
|
|
11
|
+
const arbTokens = (): fc.Arbitrary<number> => fc.integer({ min: 0, max: 10_000_000 });
|
|
12
|
+
|
|
13
|
+
// Realistic Anthropic models that should price correctly.
|
|
14
|
+
const arbModel = (): fc.Arbitrary<string> =>
|
|
15
|
+
fc.constantFrom(
|
|
16
|
+
'claude-opus-4-7',
|
|
17
|
+
'claude-sonnet-4-6',
|
|
18
|
+
'claude-haiku-4-5-20251001',
|
|
19
|
+
'claude-opus-4-6',
|
|
20
|
+
'claude-sonnet-4-5',
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const arbUsage = (): fc.Arbitrary<TokenUsage> =>
|
|
24
|
+
fc.record({
|
|
25
|
+
input_tokens: arbTokens(),
|
|
26
|
+
output_tokens: arbTokens(),
|
|
27
|
+
cache_read_input_tokens: fc.option(arbTokens(), { nil: undefined }),
|
|
28
|
+
cache_creation: fc.option(
|
|
29
|
+
fc.record({
|
|
30
|
+
ephemeral_5m_input_tokens: fc.option(arbTokens(), { nil: undefined }),
|
|
31
|
+
ephemeral_1h_input_tokens: fc.option(arbTokens(), { nil: undefined }),
|
|
32
|
+
}),
|
|
33
|
+
{ nil: undefined },
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const arbAssistantTurn = (): fc.Arbitrary<SessionTurn> =>
|
|
38
|
+
fc
|
|
39
|
+
.record({
|
|
40
|
+
usage: arbUsage(),
|
|
41
|
+
model: arbModel(),
|
|
42
|
+
sessionId: fc.string({ minLength: 8, maxLength: 36 }),
|
|
43
|
+
timestamp: fc.integer({ min: 1735689600000, max: 1798800000000 }).map(
|
|
44
|
+
(ms) => new Date(ms).toISOString(),
|
|
45
|
+
),
|
|
46
|
+
subagentType: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
|
|
47
|
+
projectId: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: undefined }),
|
|
48
|
+
parentSessionId: fc.option(fc.string({ minLength: 8, maxLength: 36 }), { nil: undefined }),
|
|
49
|
+
toolUses: fc.option(fc.array(fc.string({ minLength: 1, maxLength: 10 }), { maxLength: 5 }), {
|
|
50
|
+
nil: undefined,
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
.map((r) => ({
|
|
54
|
+
type: 'assistant' as const,
|
|
55
|
+
...r,
|
|
56
|
+
raw: r,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
describe('costFor() invariants', () => {
|
|
60
|
+
test('cost is non-negative for any valid usage + model', () => {
|
|
61
|
+
fc.assert(
|
|
62
|
+
fc.property(arbUsage(), arbModel(), (usage, model) => {
|
|
63
|
+
const c = costFor(usage, model);
|
|
64
|
+
expect(c.totalCost).toBeGreaterThanOrEqual(0);
|
|
65
|
+
expect(c.inputCost).toBeGreaterThanOrEqual(0);
|
|
66
|
+
expect(c.outputCost).toBeGreaterThanOrEqual(0);
|
|
67
|
+
expect(c.cacheReadCost).toBeGreaterThanOrEqual(0);
|
|
68
|
+
}),
|
|
69
|
+
{ numRuns: 200 },
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('totalCost equals sum of components', () => {
|
|
74
|
+
fc.assert(
|
|
75
|
+
fc.property(arbUsage(), arbModel(), (usage, model) => {
|
|
76
|
+
const c = costFor(usage, model);
|
|
77
|
+
const sum =
|
|
78
|
+
c.inputCost +
|
|
79
|
+
c.outputCost +
|
|
80
|
+
c.cacheCreation5mCost +
|
|
81
|
+
c.cacheCreation1hCost +
|
|
82
|
+
c.cacheReadCost +
|
|
83
|
+
c.serverToolUseCost;
|
|
84
|
+
// Floating point — allow tiny epsilon.
|
|
85
|
+
expect(Math.abs(c.totalCost - sum)).toBeLessThan(1e-9);
|
|
86
|
+
}),
|
|
87
|
+
{ numRuns: 200 },
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('cache_read is always cheaper than the same input as fresh tokens', () => {
|
|
92
|
+
fc.assert(
|
|
93
|
+
fc.property(arbTokens(), arbModel(), (n, model) => {
|
|
94
|
+
const fresh = costFor({ input_tokens: n, output_tokens: 0 }, model).totalCost;
|
|
95
|
+
const cached = costFor(
|
|
96
|
+
{ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: n },
|
|
97
|
+
model,
|
|
98
|
+
).totalCost;
|
|
99
|
+
// Cached should be ≤ fresh (90% discount).
|
|
100
|
+
expect(cached).toBeLessThanOrEqual(fresh + 1e-9);
|
|
101
|
+
}),
|
|
102
|
+
{ numRuns: 100 },
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('cache_creation_1h is always more expensive than cache_creation_5m for same tokens', () => {
|
|
107
|
+
fc.assert(
|
|
108
|
+
fc.property(fc.integer({ min: 1, max: 1_000_000 }), arbModel(), (n, model) => {
|
|
109
|
+
const fiveMin = costFor(
|
|
110
|
+
{
|
|
111
|
+
input_tokens: 0,
|
|
112
|
+
output_tokens: 0,
|
|
113
|
+
cache_creation: { ephemeral_5m_input_tokens: n },
|
|
114
|
+
},
|
|
115
|
+
model,
|
|
116
|
+
).totalCost;
|
|
117
|
+
const oneH = costFor(
|
|
118
|
+
{
|
|
119
|
+
input_tokens: 0,
|
|
120
|
+
output_tokens: 0,
|
|
121
|
+
cache_creation: { ephemeral_1h_input_tokens: n },
|
|
122
|
+
},
|
|
123
|
+
model,
|
|
124
|
+
).totalCost;
|
|
125
|
+
expect(oneH).toBeGreaterThanOrEqual(fiveMin - 1e-9);
|
|
126
|
+
}),
|
|
127
|
+
{ numRuns: 100 },
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('Aggregator invariants', () => {
|
|
133
|
+
test('total.cost equals sum of row.cost across any group key', () => {
|
|
134
|
+
fc.assert(
|
|
135
|
+
fc.property(fc.array(arbAssistantTurn(), { minLength: 0, maxLength: 50 }), (turns) => {
|
|
136
|
+
const agg = new Aggregator(
|
|
137
|
+
'subagent',
|
|
138
|
+
new Date('2025-01-01'),
|
|
139
|
+
new Date('2027-01-01'),
|
|
140
|
+
);
|
|
141
|
+
for (const t of turns) agg.add(t);
|
|
142
|
+
const report = agg.finalize();
|
|
143
|
+
const rowSum = report.rows.reduce((s, r) => s + r.cost.totalCost, 0);
|
|
144
|
+
expect(Math.abs(report.total.cost.totalCost - rowSum)).toBeLessThan(1e-6);
|
|
145
|
+
}),
|
|
146
|
+
{ numRuns: 100 },
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('non-assistant turns are ignored (no cost contribution)', () => {
|
|
151
|
+
fc.assert(
|
|
152
|
+
fc.property(arbUsage(), arbModel(), (usage, model) => {
|
|
153
|
+
const userTurn: SessionTurn = {
|
|
154
|
+
type: 'user',
|
|
155
|
+
usage,
|
|
156
|
+
model,
|
|
157
|
+
sessionId: 'sess',
|
|
158
|
+
timestamp: '2026-04-23T00:00:00Z',
|
|
159
|
+
raw: {},
|
|
160
|
+
};
|
|
161
|
+
const agg = new Aggregator('subagent', new Date('2025-01-01'), new Date('2027-01-01'));
|
|
162
|
+
agg.add(userTurn);
|
|
163
|
+
const report = agg.finalize();
|
|
164
|
+
expect(report.total.cost.totalCost).toBe(0);
|
|
165
|
+
}),
|
|
166
|
+
{ numRuns: 50 },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('SubagentGraph invariants', () => {
|
|
172
|
+
test('parent totalCost ≥ self cost (children only add)', () => {
|
|
173
|
+
fc.assert(
|
|
174
|
+
fc.property(
|
|
175
|
+
fc.array(arbAssistantTurn(), { minLength: 1, maxLength: 30 }),
|
|
176
|
+
(turns) => {
|
|
177
|
+
const graph = new SubagentGraph();
|
|
178
|
+
for (const t of turns) graph.add(t);
|
|
179
|
+
const roots = graph.finalize(50);
|
|
180
|
+
for (const r of roots) {
|
|
181
|
+
expect(r.totalCost).toBeGreaterThanOrEqual(r.selfCost - 1e-9);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
),
|
|
185
|
+
{ numRuns: 50 },
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|