1import { describe, expect, it } from "@effect/vitest";
2
3import { GROK_COST_USD_TICKS_PER_DOLLAR, parseGrokLine } from "./usage.ts";
4
5describe("parseGrokLine", () => {
6 /** Shaped after a real Grok Build `turn_completed` session update. */
7 function turnCompleted(overrides?: {
8 sessionId?: string;
9 promptId?: string;
10 timestamp?: number;
11 agentTimestampMs?: number;
12 usage?: Record<string, unknown>;
13 modelUsage?: Record<string, Record<string, unknown>> | null;
14 }): string {
15 const modelUsage =
16 overrides && "modelUsage" in overrides
17 ? overrides.modelUsage
18 : {
19 "grok-4.5-build": {
20 inputTokens: 20_272,
21 outputTokens: 272,
22 totalTokens: 20_544,
23 cachedReadTokens: 11_264,
24 cacheCreationTokens: 0,
25 reasoningTokens: 180,
26 costUsdTicks: 230_272_000,
27 },
28 };
29
30 return JSON.stringify({
31 timestamp: overrides?.timestamp ?? 1_786_372_566,
32 method: "_x.ai/session/update",
33 params: {
34 sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c",
35 update: {
36 sessionUpdate: "turn_completed",
37 prompt_id: overrides?.promptId ?? "prompt-1",
38 stop_reason: "end_turn",
39 usage: {
40 inputTokens: 20_272,
41 outputTokens: 272,
42 totalTokens: 20_544,
43 cachedReadTokens: 11_264,
44 cacheCreationTokens: 0,
45 reasoningTokens: 180,
46 costUsdTicks: 230_272_000,
47 ...(modelUsage === null ? {} : { modelUsage }),
48 ...overrides?.usage,
49 },
50 },
51 _meta: {
52 eventId: "event-1",
53 agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485,
54 },
55 },
56 });
57 }
58
59 it("extracts per-model totals and provider-reported cost ticks", () => {
60 const records = parseGrokLine(turnCompleted());
61
62 expect(records).toHaveLength(1);
63 const [record] = records;
64 expect(record?.provider).toBe("grok");
65 expect(record?.model).toBe("grok-4.5-build");
66 expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c");
67 expect(record?.timestampMs).toBe(1_786_372_566_485);
68 expect(record?.totals).toEqual({
69 uncachedInputTokens: 20_272 - 11_264,
70 cachedInputTokens: 11_264,
71 cacheCreationTokens: 0,
72 outputTokens: 272,
73 reasoningTokens: 180,
74 });
75 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
76 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build");
77 });
78
79 it("emits one record per model when modelUsage has several entries", () => {
80 const records = parseGrokLine(
81 turnCompleted({
82 modelUsage: {
83 "grok-4.5": {
84 inputTokens: 1000,
85 outputTokens: 50,
86 cachedReadTokens: 400,
87 reasoningTokens: 20,
88 costUsdTicks: 50_000_000,
89 },
90 "grok-composer-2.5-fast": {
91 inputTokens: 200,
92 outputTokens: 30,
93 cachedReadTokens: 100,
94 reasoningTokens: 0,
95 costUsdTicks: 10_000_000,
96 },
97 },
98 }),
99 );
100
101 expect(records.map((record) => record.model).toSorted()).toEqual([
102 "grok-4.5",
103 "grok-composer-2.5-fast",
104 ]);
105 expect(records.every((record) => record.provider === "grok")).toBe(true);
106 expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo(
107 0.005,
108 12,
109 );
110 });
111
112 it("inherits top-level cost ticks for a single model without its own ticks", () => {
113 const records = parseGrokLine(
114 turnCompleted({
115 modelUsage: {
116 "grok-4.5-build": {
117 inputTokens: 1000,
118 outputTokens: 10,
119 cachedReadTokens: 0,
120 reasoningTokens: 0,
121 },
122 },
123 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
124 }),
125 );
126
127 expect(records).toHaveLength(1);
128 expect(records[0]?.reportedCostUsd).toBe(1);
129 });
130
131 it("falls back to a generic grok model when modelUsage is absent", () => {
132 const records = parseGrokLine(turnCompleted({ modelUsage: null }));
133
134 expect(records).toHaveLength(1);
135 const [record] = records;
136 expect(record?.provider).toBe("grok");
137 expect(record?.model).toBe("grok");
138 expect(record?.totals).toEqual({
139 uncachedInputTokens: 20_272 - 11_264,
140 cachedInputTokens: 11_264,
141 cacheCreationTokens: 0,
142 outputTokens: 272,
143 reasoningTokens: 180,
144 });
145 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
146 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok");
147 });
148
149 it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => {
150 const records = parseGrokLine(
151 turnCompleted({
152 modelUsage: {
153 "grok-4.5": {
154 inputTokens: 300,
155 outputTokens: 0,
156 cachedReadTokens: 0,
157 reasoningTokens: 0,
158 },
159 "grok-composer-2.5-fast": {
160 inputTokens: 100,
161 outputTokens: 0,
162 cachedReadTokens: 0,
163 reasoningTokens: 0,
164 },
165 },
166 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
167 }),
168 );
169
170 expect(records).toHaveLength(2);
171 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
172 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
173 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
174 const sum =
175 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
176 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
177 expect(sum).toBeCloseTo(1, 12);
178 });
179
180 it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => {
181 const records = parseGrokLine(
182 turnCompleted({
183 modelUsage: {
184 "grok-4.5": {
185 inputTokens: 300,
186 outputTokens: 0,
187 cachedReadTokens: 0,
188 reasoningTokens: 0,
189 },
190 "grok-composer-2.5-fast": {
191 inputTokens: 100,
192 outputTokens: 0,
193 cachedReadTokens: 0,
194 reasoningTokens: 0,
195 },
196 "empty-sibling": {
197 inputTokens: 0,
198 outputTokens: 0,
199 cachedReadTokens: 0,
200 reasoningTokens: 0,
201 costUsdTicks: 0,
202 },
203 },
204 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
205 }),
206 );
207
208 expect(records).toHaveLength(2);
209 expect(records.every((record) => record.model !== "empty-sibling")).toBe(true);
210 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
211 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
212 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
213 const sum =
214 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
215 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
216 expect(sum).toBeCloseTo(1, 12);
217 });
218
219 it("allocates leftover aggregate ticks to models that omit per-model ticks", () => {
220 const records = parseGrokLine(
221 turnCompleted({
222 modelUsage: {
223 "grok-4.5": {
224 inputTokens: 300,
225 outputTokens: 0,
226 cachedReadTokens: 0,
227 reasoningTokens: 0,
228 costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR,
229 },
230 "grok-composer-2.5-fast": {
231 inputTokens: 100,
232 outputTokens: 0,
233 cachedReadTokens: 0,
234 reasoningTokens: 0,
235 },
236 },
237 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
238 }),
239 );
240
241 expect(records).toHaveLength(2);
242 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
243 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12);
244 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12);
245 const sum =
246 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
247 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
248 expect(sum).toBeCloseTo(1, 12);
249 });
250
251 it("does not invent a colliding dedupe key when prompt_id is missing", () => {
252 const line = JSON.stringify({
253 timestamp: 1_786_372_566,
254 method: "_x.ai/session/update",
255 params: {
256 sessionId: "s1",
257 update: {
258 sessionUpdate: "turn_completed",
259 usage: {
260 inputTokens: 10,
261 outputTokens: 2,
262 modelUsage: {
263 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
264 },
265 },
266 },
267 },
268 });
269
270 expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull();
271 });
272
273 it("ignores non-turn lines and empty usage", () => {
274 expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]);
275 expect(parseGrokLine("not json")).toEqual([]);
276 expect(
277 parseGrokLine(
278 turnCompleted({
279 modelUsage: {
280 "grok-4.5-build": {
281 inputTokens: 0,
282 outputTokens: 0,
283 cachedReadTokens: 0,
284 reasoningTokens: 0,
285 costUsdTicks: 0,
286 },
287 },
288 }),
289 ),
290 ).toEqual([]);
291 });
292
293 it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => {
294 const line = JSON.stringify({
295 timestamp: 1_786_372_566,
296 method: "_x.ai/session/update",
297 params: {
298 sessionId: "s1",
299 update: {
300 sessionUpdate: "turn_completed",
301 prompt_id: "p1",
302 usage: {
303 inputTokens: 10,
304 outputTokens: 2,
305 modelUsage: {
306 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
307 },
308 },
309 },
310 },
311 });
312
313 const records = parseGrokLine(line);
314 expect(records[0]?.timestampMs).toBe(1_786_372_566_000);
315 });
316});