1import type { UsageTokenTotals } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Path from "effect/Path";
4
5import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
6import {
7 tokenCount,
8 totalTokens,
9 type ProviderUsageReader,
10 type TranscriptUsageFormat,
11 type UsageRecord,
12} from "@t3tools/provider-core/server/usage";
13
14import type { GrokSettings } from "../settings.ts";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16
17export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000;
18
19function grokCostTicksToUsd(ticks: unknown): number | null {
20 if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null;
21 return ticks / GROK_COST_USD_TICKS_PER_DOLLAR;
22}
23
24interface GrokUsageTotals {
25 readonly inputTokens: number;
26 readonly outputTokens: number;
27 readonly cachedReadTokens: number;
28 readonly cacheCreationTokens: number;
29 readonly reasoningTokens: number;
30 readonly costUsdTicks: number | null;
31}
32
33function readGrokUsageTotals(value: unknown): GrokUsageTotals | null {
34 if (typeof value !== "object" || value === null) return null;
35 const record = value as Record<string, unknown>;
36 return {
37 inputTokens: tokenCount(record["inputTokens"]),
38 outputTokens: tokenCount(record["outputTokens"]),
39 cachedReadTokens: tokenCount(record["cachedReadTokens"]),
40 cacheCreationTokens: tokenCount(record["cacheCreationTokens"]),
41 reasoningTokens: tokenCount(record["reasoningTokens"]),
42 costUsdTicks:
43 typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"])
44 ? record["costUsdTicks"]
45 : null,
46 };
47}
48
49function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals {
50 const cachedInputTokens = totals.cachedReadTokens;
51 const cacheCreationTokens = totals.cacheCreationTokens;
52 // Grok reports `inputTokens` inclusive of the cached portion, matching Codex.
53 const uncachedInputTokens = Math.max(
54 0,
55 totals.inputTokens - cachedInputTokens - cacheCreationTokens,
56 );
57 const outputTokens = totals.outputTokens;
58 return {
59 uncachedInputTokens,
60 cachedInputTokens,
61 cacheCreationTokens,
62 outputTokens,
63 reasoningTokens: Math.min(outputTokens, totals.reasoningTokens),
64 };
65}
66
67export function parseGrokLine(line: string): readonly UsageRecord[] {
68 let parsed: unknown;
69 try {
70 parsed = JSON.parse(line);
71 } catch {
72 return [];
73 }
74 return parseGrokRecord(parsed);
75}
76
77function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
78 if (typeof parsed !== "object" || parsed === null) return [];
79
80 const record = parsed as Record<string, unknown>;
81 const params = record["params"];
82 if (typeof params !== "object" || params === null) return [];
83 const paramsRecord = params as Record<string, unknown>;
84
85 const update = paramsRecord["update"];
86 if (typeof update !== "object" || update === null) return [];
87 const updateRecord = update as Record<string, unknown>;
88 if (updateRecord["sessionUpdate"] !== "turn_completed") return [];
89
90 const usage = updateRecord["usage"];
91 if (typeof usage !== "object" || usage === null) return [];
92 const usageRecord = usage as Record<string, unknown>;
93
94 const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : "";
95 const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null;
96
97 const meta = paramsRecord["_meta"];
98 let timestampMs: number | null = null;
99 if (typeof meta === "object" && meta !== null) {
100 const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"];
101 if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) {
102 timestampMs = agentTimestampMs;
103 }
104 }
105 if (timestampMs === null) {
106 const timestamp = record["timestamp"];
107 if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
108 timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000;
109 }
110 }
111 if (timestampMs === null) return [];
112
113 const topLevel = readGrokUsageTotals(usageRecord);
114 if (topLevel === null) return [];
115
116 const modelUsage = usageRecord["modelUsage"];
117 const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = [];
118 if (typeof modelUsage === "object" && modelUsage !== null) {
119 for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
120 if (model.length === 0) continue;
121 const totals = readGrokUsageTotals(raw);
122 if (totals === null) continue;
123 modelEntries.push({ model, totals });
124 }
125 }
126
127 if (modelEntries.length === 0) {
128 if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return [];
129 return [
130 {
131 provider: "grok",
132 timestampMs,
133 model: "grok",
134 sessionId,
135 totals: grokTotalsToUsage(topLevel),
136 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
137 speed: "standard",
138 // No prompt id means we cannot tell two same-second updates apart.
139 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
140 },
141 ];
142 }
143
144 const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks);
145 let usedTickedCostUsd = 0;
146 let untickedTokenDenominator = 0;
147 for (const entry of modelEntries) {
148 const tokens = totalTokens(grokTotalsToUsage(entry.totals));
149 if (tokens === 0) continue;
150 if (entry.totals.costUsdTicks !== null) {
151 usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0;
152 } else {
153 untickedTokenDenominator += tokens;
154 }
155 }
156 const remainingCostUsd =
157 topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd);
158
159 const results: UsageRecord[] = [];
160 for (const entry of modelEntries) {
161 const totals = grokTotalsToUsage(entry.totals);
162 if (totalTokens(totals) === 0) continue;
163
164 let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks);
165 if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) {
166 reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator);
167 }
168
169 results.push({
170 provider: "grok",
171 timestampMs,
172 model: entry.model,
173 sessionId,
174 totals,
175 reportedCostUsd,
176 speed: "standard",
177 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
178 });
179 }
180 return results;
181}
182
183export const grokUsageFormat: TranscriptUsageFormat<void> = {
184 selectFields: {
185 timestamp: true,
186 params: {
187 sessionId: true,
188 _meta: { agentTimestampMs: true },
189 update: { sessionUpdate: true, prompt_id: true, usage: true },
190 },
191 },
192 usageMarkers: ['"turn_completed"'],
193 parseLine: (line) => parseGrokLine(line),
194 parseProjected: (projected) => parseGrokRecord(projected),
195};
196
197export const grokUsageReader: ProviderUsageReader<GrokSettings, Path.Path> = {
198 kind: "transcripts",
199 provider: "grok",
200 format: grokUsageFormat,
201 directories: Effect.fn("grokUsageReader.directories")(function* ({ environment }) {
202 const path = yield* Path.Path;
203 const homeDirectory = yield* HostProcess.HomeDirectory;
204 const home = expandHomePath(
205 environment.GROK_HOME?.trim() || path.join(homeDirectory, ".grok"),
206 homeDirectory,
207 );
208 // Only updates.jsonl carries usage; skip large chat_history and events logs.
209 return [{ dir: path.resolve(home, "sessions"), fileName: "updates.jsonl" }];
210 }),
211};