| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | |
| 3 | import { GROK_COST_USD_TICKS_PER_DOLLAR, parseGrokLine } from "./usage.ts"; |
| 4 | |
| 5 | describe("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 | }); |