| 1 | /** |
| 2 | * Usage history for Grok Build: the format of its per-session |
| 3 | * `updates.jsonl` logs and where each instance's `GROK_HOME` keeps them. |
| 4 | * |
| 5 | * @module provider-grok/server/usage |
| 6 | */ |
| 7 | |
| 8 | import type { UsageTokenTotals } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | |
| 12 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 13 | import { |
| 14 | tokenCount, |
| 15 | totalTokens, |
| 16 | type ProviderUsageReader, |
| 17 | type TranscriptUsageFormat, |
| 18 | type UsageRecord, |
| 19 | } from "@t3tools/provider-core/server/usage"; |
| 20 | |
| 21 | import type { GrokSettings } from "../settings.ts"; |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | |
| 24 | /** |
| 25 | * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok |
| 26 | * headless `total_cost_usd_ticks`. Convert to dollars for pricing. |
| 27 | */ |
| 28 | export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; |
| 29 | |
| 30 | function grokCostTicksToUsd(ticks: unknown): number | null { |
| 31 | if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; |
| 32 | return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; |
| 33 | } |
| 34 | |
| 35 | interface GrokUsageTotals { |
| 36 | readonly inputTokens: number; |
| 37 | readonly outputTokens: number; |
| 38 | readonly cachedReadTokens: number; |
| 39 | readonly cacheCreationTokens: number; |
| 40 | readonly reasoningTokens: number; |
| 41 | readonly costUsdTicks: number | null; |
| 42 | } |
| 43 | |
| 44 | function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { |
| 45 | if (typeof value !== "object" || value === null) return null; |
| 46 | const record = value as Record<string, unknown>; |
| 47 | return { |
| 48 | inputTokens: tokenCount(record["inputTokens"]), |
| 49 | outputTokens: tokenCount(record["outputTokens"]), |
| 50 | cachedReadTokens: tokenCount(record["cachedReadTokens"]), |
| 51 | cacheCreationTokens: tokenCount(record["cacheCreationTokens"]), |
| 52 | reasoningTokens: tokenCount(record["reasoningTokens"]), |
| 53 | costUsdTicks: |
| 54 | typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) |
| 55 | ? record["costUsdTicks"] |
| 56 | : null, |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { |
| 61 | const cachedInputTokens = totals.cachedReadTokens; |
| 62 | const cacheCreationTokens = totals.cacheCreationTokens; |
| 63 | // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. |
| 64 | const uncachedInputTokens = Math.max( |
| 65 | 0, |
| 66 | totals.inputTokens - cachedInputTokens - cacheCreationTokens, |
| 67 | ); |
| 68 | const outputTokens = totals.outputTokens; |
| 69 | return { |
| 70 | uncachedInputTokens, |
| 71 | cachedInputTokens, |
| 72 | cacheCreationTokens, |
| 73 | outputTokens, |
| 74 | reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), |
| 75 | }; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Parses one line of a Grok Build `updates.jsonl` session log. |
| 80 | * |
| 81 | * Usage lands on `turn_completed` session updates. Per-model breakdowns live |
| 82 | * under `usage.modelUsage`; when present each model becomes its own record. |
| 83 | * |
| 84 | * Returns every record for the line (0 or more). Callers stream line-by-line |
| 85 | * and flatten. |
| 86 | */ |
| 87 | export function parseGrokLine(line: string): readonly UsageRecord[] { |
| 88 | let parsed: unknown; |
| 89 | try { |
| 90 | parsed = JSON.parse(line); |
| 91 | } catch { |
| 92 | return []; |
| 93 | } |
| 94 | return parseGrokRecord(parsed); |
| 95 | } |
| 96 | |
| 97 | function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { |
| 98 | if (typeof parsed !== "object" || parsed === null) return []; |
| 99 | |
| 100 | const record = parsed as Record<string, unknown>; |
| 101 | const params = record["params"]; |
| 102 | if (typeof params !== "object" || params === null) return []; |
| 103 | const paramsRecord = params as Record<string, unknown>; |
| 104 | |
| 105 | const update = paramsRecord["update"]; |
| 106 | if (typeof update !== "object" || update === null) return []; |
| 107 | const updateRecord = update as Record<string, unknown>; |
| 108 | if (updateRecord["sessionUpdate"] !== "turn_completed") return []; |
| 109 | |
| 110 | const usage = updateRecord["usage"]; |
| 111 | if (typeof usage !== "object" || usage === null) return []; |
| 112 | const usageRecord = usage as Record<string, unknown>; |
| 113 | |
| 114 | const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; |
| 115 | const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; |
| 116 | |
| 117 | // Prefer the high-resolution agent clock; fall back to the outer unix seconds. |
| 118 | const meta = paramsRecord["_meta"]; |
| 119 | let timestampMs: number | null = null; |
| 120 | if (typeof meta === "object" && meta !== null) { |
| 121 | const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"]; |
| 122 | if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { |
| 123 | timestampMs = agentTimestampMs; |
| 124 | } |
| 125 | } |
| 126 | if (timestampMs === null) { |
| 127 | const timestamp = record["timestamp"]; |
| 128 | if (typeof timestamp === "number" && Number.isFinite(timestamp)) { |
| 129 | timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; |
| 130 | } |
| 131 | } |
| 132 | if (timestampMs === null) return []; |
| 133 | |
| 134 | const topLevel = readGrokUsageTotals(usageRecord); |
| 135 | if (topLevel === null) return []; |
| 136 | |
| 137 | const modelUsage = usageRecord["modelUsage"]; |
| 138 | const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; |
| 139 | if (typeof modelUsage === "object" && modelUsage !== null) { |
| 140 | for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) { |
| 141 | if (model.length === 0) continue; |
| 142 | const totals = readGrokUsageTotals(raw); |
| 143 | if (totals === null) continue; |
| 144 | modelEntries.push({ model, totals }); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | if (modelEntries.length === 0) { |
| 149 | if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; |
| 150 | return [ |
| 151 | { |
| 152 | provider: "grok", |
| 153 | timestampMs, |
| 154 | model: "grok", |
| 155 | sessionId, |
| 156 | totals: grokTotalsToUsage(topLevel), |
| 157 | reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), |
| 158 | speed: "standard", |
| 159 | // No prompt id means we cannot tell two same-second updates apart. |
| 160 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, |
| 161 | }, |
| 162 | ]; |
| 163 | } |
| 164 | |
| 165 | // Cost allocation: |
| 166 | // 1. Emitted models with their own costUsdTicks keep those values. |
| 167 | // 2. Remaining aggregate cost (top-level minus those per-model ticks, |
| 168 | // clamped at 0) is pro-rated across emitted models that lack ticks, |
| 169 | // by token share among the unticked models only. |
| 170 | // 3. When no model has per-model ticks, remaining equals the full |
| 171 | // aggregate and every emitted model gets a token-share slice. |
| 172 | // Zero-token rows are never emitted and never count toward used ticks. |
| 173 | const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); |
| 174 | let usedTickedCostUsd = 0; |
| 175 | let untickedTokenDenominator = 0; |
| 176 | for (const entry of modelEntries) { |
| 177 | const tokens = totalTokens(grokTotalsToUsage(entry.totals)); |
| 178 | if (tokens === 0) continue; |
| 179 | if (entry.totals.costUsdTicks !== null) { |
| 180 | usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; |
| 181 | } else { |
| 182 | untickedTokenDenominator += tokens; |
| 183 | } |
| 184 | } |
| 185 | const remainingCostUsd = |
| 186 | topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); |
| 187 | |
| 188 | const results: UsageRecord[] = []; |
| 189 | for (const entry of modelEntries) { |
| 190 | const totals = grokTotalsToUsage(entry.totals); |
| 191 | if (totalTokens(totals) === 0) continue; |
| 192 | |
| 193 | let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); |
| 194 | if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { |
| 195 | reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); |
| 196 | } |
| 197 | |
| 198 | results.push({ |
| 199 | provider: "grok", |
| 200 | timestampMs, |
| 201 | model: entry.model, |
| 202 | sessionId, |
| 203 | totals, |
| 204 | reportedCostUsd, |
| 205 | speed: "standard", |
| 206 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, |
| 207 | }); |
| 208 | } |
| 209 | return results; |
| 210 | } |
| 211 | |
| 212 | export const grokUsageFormat: TranscriptUsageFormat<void> = { |
| 213 | selectFields: { |
| 214 | timestamp: true, |
| 215 | params: { |
| 216 | sessionId: true, |
| 217 | _meta: { agentTimestampMs: true }, |
| 218 | update: { sessionUpdate: true, prompt_id: true, usage: true }, |
| 219 | }, |
| 220 | }, |
| 221 | usageMarkers: ['"turn_completed"'], |
| 222 | parseLine: (line) => parseGrokLine(line), |
| 223 | parseProjected: (projected) => parseGrokRecord(projected), |
| 224 | }; |
| 225 | |
| 226 | export const grokUsageReader: ProviderUsageReader<GrokSettings, Path.Path> = { |
| 227 | kind: "transcripts", |
| 228 | provider: "grok", |
| 229 | format: grokUsageFormat, |
| 230 | directories: Effect.fn("grokUsageReader.directories")(function* ({ environment }) { |
| 231 | const path = yield* Path.Path; |
| 232 | const homeDirectory = yield* HostProcess.HomeDirectory; |
| 233 | const home = expandHomePath( |
| 234 | environment.GROK_HOME?.trim() || path.join(homeDirectory, ".grok"), |
| 235 | homeDirectory, |
| 236 | ); |
| 237 | // Sessions also ship multi-megabyte `chat_history` and `events` logs that |
| 238 | // never carry usage; only `updates.jsonl` does. |
| 239 | return [{ dir: path.resolve(home, "sessions"), fileName: "updates.jsonl" }]; |
| 240 | }), |
| 241 | }; |